Introduction
Chassis is an embedded vector index for local semantic search, written in Rust. It keeps an index in one file and runs inside your process, with no server to run or connect to.
What Chassis Does
Chassis stores vectors under your ids and searches them. It manages a single memory-mapped file that holds both the vectors and a persistent HNSW graph over them.
Key Capabilities
- Vector Similarity Search: Performs approximate nearest neighbor (ANN) search using a fully persistent HNSW graph.
- High-Level Orchestration: The
VectorIndexfacade manages the complexity of coordinate storage, graph topology, and search logic. - Crash Consistency: After a crash, reopening keeps every add and delete up to the last
flush()and drops later ones; graph edges changed after that flush may be partly lost, which can lower recall. (ADR-005). - Memory-Mapped Access: Searches read vectors straight from the operating system’s page cache, so opening an index loads nothing and takes about a millisecond.
What Chassis Does Not Do
Chassis is intentionally limited in scope to ensure correctness and performance. It is not:
- A Database Server: There is no network listener, SQL interface, or daemon.
- A Distributed System: Replication and sharding are left to the application layer.
- A Metadata Store: Chassis stores vectors and IDs only. You should map these IDs to your application data (JSON, text, etc.) using a separate store like SQLite, and pass the ids it selects to
search_filteredto search among them.
Current Status
The latest release is v0.7.2: deletes and your own ids, filtered search, compaction, readers in other processes, half precision and more, in a new file format that v0.6.3 can’t open. Opening an older file for writing converts it. Search, the C API and the Python bindings work end to end. Measured numbers are in the decision records and on the Performance page. To replace a vector, delete its id and add it again.
Requirements
- Rust 1.88 or later
- A filesystem that supports memory mapping (Linux, macOS, Windows) and
fsyncfor durability.
License
Chassis is dual-licensed under MIT and Apache 2.0. You may use either license at your option.
Getting Started
Installation
Add Chassis to your Cargo.toml:
[dependencies]
chassis-core = "0.7"
Quick Start
use chassis_core::{VectorIndex, IndexOptions};
use anyhow::Result;
fn main() -> Result<()> {
// 1. Open the index (creates file if missing)
let mut index = VectorIndex::open(
"quickstart.chassis",
3, // 3D vectors for demo
IndexOptions::default()
)?;
// 2. Add some data
index.add(&[1.0, 0.0, 0.0])?; // ID 0
index.add(&[0.0, 1.0, 0.0])?; // ID 1
index.add(&[0.0, 0.0, 1.0])?; // ID 2
index.flush()?;
// 3. Search
let query = vec![1.0, 0.1, 0.0];
let results = index.search(&query, 1)?;
assert_eq!(results[0].id, 0); // Should match ID 0 best
println!("Nearest neighbor: ID {}", results[0].id);
Ok(())
}
Basic Usage
Workflow Overview
Using Chassis involves three steps:
- Open: Initialize the
VectorIndex. - Write: Add vectors and flush to disk.
- Read: Search for nearest neighbors.
1. Initialization
#![allow(unused)]
fn main() {
use chassis_core::{VectorIndex, IndexOptions};
let options = IndexOptions {
max_connections: 16,
ef_construction: 200,
ef_search: 50,
..IndexOptions::default()
};
let mut index = VectorIndex::open("./data.chassis", 1536, options)?;
}
2. Insertion
Chassis assigns a sequential ID (u64) to every vector you add. You should store this ID in your external database (e.g., SQLite/Postgres) to map it back to your application data.
#![allow(unused)]
fn main() {
let embedding = compute_embedding("Example text"); // [f32; 1536]
let id = index.add(&embedding)?;
println!("Stored vector at ID: {}", id);
// Important: Commit to disk!
index.flush()?;
}
3. Search
Search is thread-safe and can run concurrently with other searches (but not with writes). Other
processes can search the same file with IndexReader while this one writes.
#![allow(unused)]
fn main() {
let query = compute_embedding("Search query");
let k = 5;
let results = index.search(&query, k)?;
println!("Found {} matches:", results.len());
for res in results {
println!("- ID: {}, Score: {}", res.id, res.distance);
}
}
API Reference
Core Facade
VectorIndex
The primary entry point for Chassis. VectorIndex orchestrates the storage layer, graph topology, and search index into a single, crash-consistent unit.
Opening an Index
#![allow(unused)]
fn main() {
use chassis_core::{VectorIndex, IndexOptions};
// Open or create a persistent index
let mut index = VectorIndex::open(
"embeddings.chassis",
768,
IndexOptions::default()
)?;
}
Parameters:
path: Path to the backing file (created if missing).dims: Vector dimensionality (must be static for the file’s lifetime).options: Configuration for graph construction and search.
Returns:
Ok(VectorIndex): Handle to the index.Err: If file is locked, corrupted, or dimensions mismatch.
Adding Vectors
#![allow(unused)]
fn main() {
let vector = vec![0.5; 768];
let id = index.add(&vector)?;
}
Behavior:
- Crash behavior: If the process dies before the next
flush(), this vector is gone on reopen. The index stays usable. - Ids:
addreturns one past the largest id used so far (0, 1, 2… unless you chose ids yourself). It never reuses an id, even after a delete. - Durability: Data is written to memory-mapped pages immediately but requires
flush()for persistence guarantees.
Errors:
- Dimension mismatch.
- Storage write failure (e.g., disk full).
Your Own Ids
#![allow(unused)]
fn main() {
index.add_with_id(42, &vector)?;
}
Search results report 42. Fails if a live vector already has that id; delete it first to replace
it. u64::MAX is reserved. Indexes that use their own ids build an id table in memory on the first
lookup in a process, which scans the index once (ADR-0007).
Adding Many Vectors
#![allow(unused)]
fn main() {
let vectors: Vec<f32> = rows.concat(); // count × dims floats, back to back
let ids = index.add_batch(&vectors)?; // or index.add_batch_with_ids(&my_ids, &vectors)?
}
Links the batch on every core, so a large batch builds many times faster than add in a loop
(ADR-0010). Ids are assigned as add assigns them. A batch is
added whole or not at all. The graph depends on thread timing, so two builds of the same data
differ slightly, as sequential builds already do through their random layers.
Deleting
#![allow(unused)]
fn main() {
let deleted = index.delete(42)?; // false if no live vector has id 42
}
Search stops returning the vector immediately, and len() drops by one. The delete is durable
after the next flush(); a crash before then rolls it back. A delete and an add in the same flush
are all-or-nothing, so delete(id) followed by add_with_id(id, new_vector) replaces a vector
safely. Deleted vectors keep their disk space until compact().
Compacting
#![allow(unused)]
fn main() {
index.compact()?;
}
Rewrites the index without its deleted vectors and with a newly built graph, then replaces the
file with the copy (ADR-0011). Ids don’t change, and add still never
reuses one. Like flush(), it makes every add and delete so far durable. It takes as long as
building the index, on every core, and needs free disk for a second copy of the live vectors.
Readers in other processes keep searching and move to the new file by themselves. On Windows it
fails, leaving the index as it was, while another process has the index open.
Searching
#![allow(unused)]
fn main() {
let query = vec![0.5; 768];
let k = 10; // Neighbors to retrieve
let results = index.search(&query, k)?;
for match in results {
println!("ID: {}, Distance: {}", match.id, match.distance);
}
}
Returns: Vec<SearchResult>, sorted by distance (nearest first), identified by the ids from add/add_with_id. Deleted vectors are never returned.
Filtered search
#![allow(unused)]
fn main() {
// The ids your application allows, e.g. from `SELECT id FROM docs WHERE owner = ?`
let allowed: HashSet<u64> = owner_doc_ids();
let results = index.search_filtered(&query, 10, |id| allowed.contains(&id))?;
}
Only vectors whose id the filter accepts are returned. The filter is called with ids from
add/add_with_id, many times per search, so it should be cheap. When the filter passes many
vectors, the search walks the graph as usual; when it passes few, or its matches lie far from the
query, walking the graph would visit most of the index to find them, so the search checks every
vector instead and returns the exact nearest. Measured, a filtered search takes at most about twice an exact scan of the matching
vectors (ADR-0009). IndexReader has the same method.
Persistence
#![allow(unused)]
fn main() {
// Flush all pending writes to physical disk (fsync)
index.flush()?;
}
Recommendation: flush() is an expensive syscall. Call it after a batch of insertions (e.g., every 1,000 vectors) or before shutting down.
Metadata
#![allow(unused)]
fn main() {
let len = index.len(); // Live vectors (deleted ones excluded)
let dim = index.dimensions(); // Vector size
let metric = index.metric(); // DistanceMetric::Euclidean or ::Cosine
let precision = index.precision(); // Precision::Full or ::Half
let empty = index.is_empty(); // True if count == 0
}
IndexReader
Searches an index while a VectorIndex in another process writes it. It takes no lock, so any
number of processes can open readers on one file next to its single writer
(ADR-0008, decision 5).
#![allow(unused)]
fn main() {
use chassis_core::{IndexOptions, IndexReader};
let mut reader = IndexReader::open("embeddings.chassis", 768, IndexOptions::default())?;
let results = reader.search(&query, 10)?;
}
Every search first takes a new snapshot: it returns what the writer’s newest flush() committed,
and nothing that flush deleted, while routing through vectors added since, so a long unflushed batch
costs readers little recall. A flush becomes visible just before its final fsync, so one that then
fails, or is lost to power loss, may already have been seen. len() reports the last snapshot;
refresh() takes a new one without searching, and snapshot() identifies it, for keying a cache.
search takes &mut self: open one reader per thread. The file must exist in the current format;
open it once with VectorIndex::open to create or migrate it.
Errors
VectorIndex and IndexReader return chassis_core::Result<T>, whose error says what was wrong,
with the value, and on a line starting help: what to do instead. Error::kind() is an
ErrorKind to act on (Errors lists them, with causes and fixes):
#![allow(unused)]
fn main() {
use chassis_core::{ErrorKind, IndexOptions, IndexReader, VectorIndex};
match VectorIndex::open("embeddings.chassis", 768, IndexOptions::default()) {
Ok(index) => { /* write */ }
// One writer at a time; a reader searches while it writes.
Err(e) if e.kind() == ErrorKind::Locked => {
let reader = IndexReader::open("embeddings.chassis", 768, IndexOptions::default())?;
}
Err(e) => return Err(e.into()),
}
}
chassis_core::Error converts into anyhow::Error and Box<dyn std::error::Error>, so ? works
in functions that return those.
Configuration
IndexOptions
Parameters tuning the HNSW graph trade-offs between recall, speed, and memory.
#![allow(unused)]
fn main() {
pub struct IndexOptions {
/// Max connections per node (M). Default: 16
pub max_connections: u16,
/// Size of the dynamic candidate list during construction. Default: 200
/// Higher = Better graph quality, slower inserts.
pub ef_construction: usize,
/// Size of the dynamic candidate list during search. Default: 50
/// Higher = Better recall, slower search.
pub ef_search: usize,
/// `DistanceMetric::Euclidean` (default) or `DistanceMetric::Cosine`.
pub metric: DistanceMetric,
/// `Precision::Full` (default) or `Precision::Half`.
pub precision: Precision,
/// Ask the operating system to keep the vectors on huge pages. Default: false
pub huge_pages: bool,
/// Read the index into memory on another thread from the moment it is opened. Default: false
pub warm: bool,
}
}
The metric is fixed when the index is created: reopening with another one is an error, an
IndexReader uses the file’s, and metric() on either reports it. A cosine index stores vectors
scaled to unit length, rejects zero vectors and vectors with NaN or infinite components, and
reports 1 - cosine similarity, from 0 to 2. Search speed is the same for both.
precision is fixed when the index is created, as the metric is: reopening with another one is
an error, an IndexReader uses the file’s, and precision() on either reports it.
Precision::Half keeps each component of a vector as a 16-bit float
(ADR-0018), so the vectors take half the space in the file and in
memory. A search over an index too large for the CPU’s caches is faster for it, by a tenth to
two thirds on most of the machines measured, and builds on x86 take 11 to 48% less time. An index
that fits in cache searches no faster, and on Neoverse-N2 a fifth more slowly when its vectors are
long. Each component is rounded to 11 significant bits, about three decimal digits, and reported
distances are to the vectors as kept; on the embeddings measured, recall was the same. A vector
with a component of 65,520 or more in magnitude is refused. A file in half precision is format
version 4, which releases from before this option refuse to open.
Besides half precision, the way to shrink an index is fewer dimensions. Some embedding models are
trained so that the first part of a vector works on its own (“Matryoshka” embeddings, such as
OpenAI’s text-embedding-3 with its dimensions argument): 512 of 1,536 dimensions make the index
about a third of the size. Measure recall on your own queries first, and cut vectors yourself only
with DistanceMetric::Cosine, or normalize them again. max_connections: 12 instead of 16 saves
less: a 128-dimension file in half precision is 7% smaller and one of 1,536 dimensions 1%, and
builds are 7 to 18% faster, but searches for 0.99 recall at 128 and 384 dimensions take up to 14%
longer.
huge_pages is for an index too large for the CPU’s caches: with the vectors on 2 MB pages,
searches are up to a quarter faster and batch builds a little faster
(ADR-0016). It works on Linux, where the kernel and
filesystem keep files on huge pages (ext4 on Linux 6.17 does), and does nothing elsewhere. A writer
and its readers each ask for themselves. It is off by default because a page not yet in memory is
then read 2 MB at a time, which an index much larger than memory pays for on every miss.
VectorIndex::use_huge_pages and IndexReader::use_huge_pages turn it on after opening.
warm is for an index whose file is not in memory yet, as after a reboot. Until it is, each page
a search touches is read from the disk, one read at a time. With warm, another thread reads in
what the index holds, at the disk’s sequential speed, while searches go on
(ADR-0019). On an Apple M5, from the start of a process, the hundredth search over 99,000
vectors of 1,536 dimensions returned after 0.37 s with it and 1.9 s without; on Linux servers
with slower disks, after 1.3 to 1.7 s where it took 1.5 to 2.9 s. It reads only what has been written, and changes
nothing in the file. It is off by default because an index much larger than memory would push
everything else out, itself included, and on a slow disk reading the whole file can take longer
than the searches would. It takes Linux 5.14 or later, or macOS; an older Linux reads
in only some of the index, and Windows none. VectorIndex::warm and IndexReader::warm ask for
it after opening and return at once; from then on the option is on, so a reader that opens the
file again after a compaction reads the new one in too.
Tuning Guide:
- High Recall: Increase
ef_constructionto 400 andmax_connectionsto 32. - Fast Search: Decrease
ef_searchto 20-30. - Low Memory: Decrease
max_connectionsto 8-12.
Data Types
SearchResult
#![allow(unused)]
fn main() {
pub struct SearchResult {
/// The internal sequential ID of the vector
pub id: u64,
/// Distance from the query by the index's metric
pub distance: f32,
}
}
Chassis FFI - C Bindings for Chassis Vector Storage
This crate provides a C-compatible shared library interface to Chassis, an embedded vector index.
Features
- Panic-safe: All panics are caught at the FFI boundary
- Thread-safe error handling: Thread-local error storage
- ABI stable:
#[repr(C)]guarantees - Zero-copy where possible: Direct mmap access for search operations
- Comprehensive documentation: Every function documented with safety requirements
Building
cd chassis-ffi
cargo build --release
This generates:
- Linux:
target/release/libchassis_ffi.so - macOS:
target/release/libchassis_ffi.dylib - Windows:
target/release/chassis_ffi.dll
The C header is automatically generated at include/chassis.h.
Usage
Basic Example (C)
#include "chassis.h"
#include <stdio.h>
int main(void) {
// Open index
ChassisIndex* index = chassis_open("vectors.chassis", 768);
if (index == NULL) {
fprintf(stderr, "Error: %s\n", chassis_last_error_message());
return 1;
}
// Add vector
float vec[768] = {0.1, 0.2, /* ... */};
uint64_t id = chassis_add(index, vec, 768);
if (id == UINT64_MAX) {
fprintf(stderr, "Error: %s\n", chassis_last_error_message());
chassis_free(index);
return 1;
}
// Flush to disk
if (chassis_flush(index) != 0) {
fprintf(stderr, "Error: %s\n", chassis_last_error_message());
chassis_free(index);
return 1;
}
// Search
float query[768] = {0.1, 0.2, /* ... */};
uint64_t ids[10];
float distances[10];
size_t count = chassis_search(index, query, 768, 10, ids, distances);
for (size_t i = 0; i < count; i++) {
printf("ID: %llu, Distance: %f\n", ids[i], distances[i]);
}
// Clean up
chassis_free(index);
return 0;
}
Compilation
# Linux
gcc -o myapp myapp.c -L./target/release -lchassis_ffi -lm
# Run (Linux)
LD_LIBRARY_PATH=./target/release ./myapp
# macOS
gcc -o myapp myapp.c -L./target/release -lchassis_ffi
# Run (macOS)
DYLD_LIBRARY_PATH=./target/release ./myapp
# Windows (MSVC)
cl myapp.c /I include /link /LIBPATH:target\release chassis_ffi.lib
API Reference
Lifecycle
chassis_open
ChassisIndex* chassis_open(const char* path, uint32_t dimensions);
Open or create an index. Returns NULL on error, including when the file is a cosine index or
one in half precision: open those with chassis_open_with_precision or chassis_open_reader.
chassis_open_with_options
ChassisIndex* chassis_open_with_options(
const char* path,
uint32_t dimensions,
uint32_t max_connections,
uint32_t ef_construction,
uint32_t ef_search
);
Open with custom HNSW parameters.
chassis_open_with_metric
ChassisIndex* chassis_open_with_metric(
const char* path,
uint32_t dimensions,
uint32_t max_connections,
uint32_t ef_construction,
uint32_t ef_search,
uint32_t metric
);
Like chassis_open_with_options, with a distance metric: 0 for Euclidean, 1 for cosine
(1 - cosine similarity; vectors are stored at unit length and zero vectors are rejected). The
metric is fixed when the index is created; reopening with another one fails.
chassis_open_with_precision
ChassisIndex* chassis_open_with_precision(
const char* path,
uint32_t dimensions,
uint32_t max_connections,
uint32_t ef_construction,
uint32_t ef_search,
uint32_t metric,
uint32_t precision
);
Like chassis_open_with_metric, with a precision: 0 keeps each component of a vector as a
32-bit float, 1 as a 16-bit float (ADR-0018). Half precision
halves the vectors’ size in the file and in memory, and a search over an index too large for the
CPU’s caches is faster for it. Each component is rounded to about three decimal digits, and a
vector with a component of 65,520 or more in magnitude is refused. The precision is fixed when
the index is created; reopening with another one fails, and a release without this function
can’t open a file in half precision.
chassis_open_reader
ChassisIndex* chassis_open_reader(
const char* path,
uint32_t dimensions,
uint32_t max_connections,
uint32_t ef_search
);
Open an existing index to search it while a writer, possibly in another process, adds to it. Takes
no lock, so any number of readers can open the file next to one writer. Each search sees the
writer’s newest flush, which becomes visible just before its final fsync; chassis_len takes a new
snapshot first. Adds, deletes and flushes on the
handle fail. A file in an older format must be opened once with chassis_open to migrate it.
chassis_use_huge_pages
int chassis_use_huge_pages(ChassisIndex* index);
Ask the operating system to keep the index’s vectors on huge pages
(ADR-0016). On an index too large for the CPU’s caches,
searches are up to a quarter faster. Linux only, where the kernel and filesystem keep files on huge pages
(ext4 on Linux 6.17 does); elsewhere it does nothing. Call it right after opening, on a writer’s
handle or a reader’s. Returns 0, or -1 for a NULL handle.
chassis_warm
int chassis_warm(ChassisIndex* index);
Read the index into memory on another thread (ADR-0019), so that searches
stop waiting for the disk one page at a time while its file is not in memory yet, as after a
reboot. Doesn’t wait for the reading: searches go on meanwhile. What is read is what the index
holds when this is called; while that is under way, calling again does nothing. A reader that opens
the file again after a compaction reads the new one in too. For an index that fits in memory: one
much larger would push everything else out. Linux 5.14 or macOS; an older Linux reads only some of
the index, and Windows none. Call it on a writer’s handle or a reader’s, right after opening or
later. Returns 0, or -1 for a NULL handle.
chassis_free
void chassis_free(ChassisIndex* index);
Free an index and release resources. Safe to call with NULL.
Operations
chassis_add
uint64_t chassis_add(
ChassisIndex* index,
const float* vector,
size_t len
);
Add a vector. Returns the id assigned (one past the largest id used so far) or UINT64_MAX on error.
Thread Safety: Safe from any thread; writes run one at a time and searches wait for them
chassis_add_with_id
int chassis_add_with_id(
ChassisIndex* index,
uint64_t id,
const float* vector,
size_t len
);
Add a vector under your own id; search reports it. Returns 0, or -1 if a live vector already
has id, id is UINT64_MAX, or the add fails.
Thread Safety: Safe from any thread; writes run one at a time and searches wait for them
chassis_add_batch
size_t chassis_add_batch(
ChassisIndex* index,
const float* vectors, // count * dim floats, row-major
size_t count,
size_t dim,
uint64_t* out_ids // room for count ids
);
Add many vectors at once, linking them on every core: a large batch builds many times faster than
chassis_add in a loop (ADR-0010). Writes their ids, assigned
as chassis_add assigns them, to out_ids. Returns count, or 0 if the batch fails, in which
case none of it is added.
chassis_add_batch_with_ids
int chassis_add_batch_with_ids(
ChassisIndex* index,
const uint64_t* ids, // count ids
const float* vectors, // count * dim floats, row-major
size_t count,
size_t dim
);
chassis_add_with_id for a whole batch, linked on every core. Returns 0, or -1 with none of the
batch added if an id repeats, already exists or is UINT64_MAX.
Thread Safety (both): a batch holds the write lock until it is linked, so searches on the same handle wait for the whole batch.
chassis_delete
int chassis_delete(ChassisIndex* index, uint64_t id);
Delete the vector with id. Returns 1 if deleted, 0 if no live vector has id, -1 on error.
Durable after the next chassis_flush(); a crash before then rolls it back.
Thread Safety: Safe from any thread; writes run one at a time and searches wait for them
chassis_search
size_t chassis_search(
const ChassisIndex* index,
const float* query,
size_t len,
size_t k,
uint64_t* out_ids,
float* out_dists
);
Search for k nearest neighbors. Returns number of results found.
Thread Safety: Safe from any thread; runs concurrently with other searches
chassis_search_filtered
size_t chassis_search_filtered(
const ChassisIndex* index,
const float* query,
size_t len,
size_t k,
const uint64_t* allowed_ids,
size_t allowed_len,
uint64_t* out_ids,
float* out_dists
);
Like chassis_search, returning only vectors whose id is in allowed_ids. Ids not in the index
are ignored; an empty list matches nothing. When walking the graph would cost more, as when few
vectors match, every vector is checked instead and the results are exact.
chassis_search_batch
size_t chassis_search_batch(
const ChassisIndex* index,
const float* queries,
size_t count,
size_t dim,
size_t k,
uint64_t* out_ids,
float* out_dists
);
Search for the k nearest neighbors of each of count queries, given one after another, dim
floats each. out_ids and out_dists hold count * k values: row i is query i’s results,
nearest first, and a row with fewer than k is filled out with id UINT64_MAX, which no vector
has, and distance INFINITY. Returns count, or 0 on error or for an empty batch, which sets no
error. An error names the query that failed, as in Query 3 of the batch has NaN at component 0.
Thread Safety: Safe from any thread; runs concurrently with other searches, and a write waits for the whole batch
chassis_flush
int chassis_flush(ChassisIndex* index);
Flush changes to disk. Returns 0 on success, -1 on error.
Thread Safety: Safe from any thread; writes run one at a time and searches wait for them
chassis_compact
int chassis_compact(ChassisIndex* index);
Rewrite the index without its deleted vectors and with a newly built graph, then replace the file
with the copy (ADR-0011). Ids don’t change. Like chassis_flush, it
makes every add and delete so far durable. Returns 0, or -1 with the index left as it was; on
Windows it fails while another process has the index open. It takes as long as building the index
and needs free disk for a second copy.
Thread Safety: holds the write lock for its whole run, so searches on this handle wait; readers in other processes keep searching
Introspection
chassis_len
uint64_t chassis_len(const ChassisIndex* index);
Get number of vectors in the index.
chassis_is_empty
int chassis_is_empty(const ChassisIndex* index);
Check if index is empty. Returns 1 if empty, 0 otherwise.
chassis_dimensions
uint32_t chassis_dimensions(const ChassisIndex* index);
Get vector dimensionality.
chassis_metric
int chassis_metric(const ChassisIndex* index);
The metric the index was created with: 0 Euclidean, 1 cosine, -1 if index is NULL.
chassis_precision
int chassis_precision(const ChassisIndex* index);
The precision the index keeps its vectors in: 0 full, 1 half, -1 if index is NULL.
Error Handling
chassis_last_error_message
const char* chassis_last_error_message(void);
The last error on this thread: what was wrong, with the value, then a line starting help: with
what to do instead. Returns NULL if no error.
Lifetime: Valid until next FFI call on this thread.
chassis_last_error_code
int chassis_last_error_code(void);
The last error’s kind, for a program to act on: one of the CHASSIS_ERROR_ constants
(CHASSIS_ERROR_INVALID_ARGUMENT, CHASSIS_ERROR_DIMENSION_MISMATCH, CHASSIS_ERROR_LOCKED, …),
or CHASSIS_OK (0) after a call that succeeded. Errors lists them, with their
causes and fixes.
Versioning
chassis_version
const char* chassis_version(void);
Get library version string.
Thread Safety
Every function except chassis_free is safe to call from any thread on the same handle. Searches,
chassis_len, chassis_is_empty and chassis_dimensions run concurrently. chassis_add,
chassis_add_with_id, chassis_add_batch, chassis_delete and chassis_flush take the handle’s
write lock, so they run one at a time and searches wait for them. chassis_free must not race with
any other call on the same handle. A handle from chassis_open_reader runs one call at a time,
introspection included; open one per thread to search in parallel.
Concurrency Example
// Thread 1: Writer
void* writer_thread(void* arg) {
ChassisIndex* index = (ChassisIndex*)arg;
// Each add takes the write lock; searches in other threads wait for it
float vec[768];
for (int i = 0; i < 1000; i++) {
generate_vector(vec, i);
chassis_add(index, vec, 768);
}
chassis_flush(index);
return NULL;
}
// Thread 2: Reader
void* reader_thread(void* arg) {
const ChassisIndex* index = (const ChassisIndex*)arg;
// Runs concurrently with other searches, and waits while a write runs
float query[768];
uint64_t ids[10];
float dists[10];
while (keep_running) {
generate_query(query);
chassis_search(index, query, 768, 10, ids, dists);
process_results(ids, dists);
}
return NULL;
}
Error Handling Patterns
Pattern 1: Check Return Value
uint64_t id = chassis_add(index, vec, 768);
if (id == UINT64_MAX) {
fprintf(stderr, "Error: %s\n", chassis_last_error_message());
// Handle error
}
Pattern 2: Check and Cleanup
if (chassis_flush(index) != 0) {
const char* error = chassis_last_error_message();
fprintf(stderr, "Flush failed: %s\n", error ? error : "Unknown error");
chassis_free(index);
exit(1);
}
Pattern 3: Thread-Local Errors
// Thread A sets error
chassis_add(index, vec, 768); // Fails
printf("Thread A: %s\n", chassis_last_error_message());
// Thread B has different error storage
// (no race condition)
Safety Requirements
All functions document their safety requirements. Key rules:
- Null Checks: Never pass
NULLunless explicitly allowed - Lifetime: Pointers from
chassis_last_error_message()are only valid until next FFI call - Dimensions: Vector length must match index dimensions
- Thread Safety: Don’t call
chassis_freewhile other threads still use the handle - Double Free: Don’t use pointers after
chassis_free()
Performance Tips
- Batch inserts: Add many vectors before calling
chassis_flush() - Reuse buffers: Allocate result buffers once, reuse for multiple searches
- Tune parameters: Adjust
max_connections,ef_construction,ef_searchfor your use case
Panic Safety
All panics are caught at the FFI boundary and converted to errors:
// Even if Rust code panics internally, this will return an error
uint64_t id = chassis_add(index, vec, 768);
if (id == UINT64_MAX) {
// Error message will contain "Panic: ..." if a panic occurred
fprintf(stderr, "%s\n", chassis_last_error_message());
}
This is a critical safety guarantee - undefined behavior will never occur due to unwinding across the FFI boundary.
Examples
See examples/example.c for a complete working example.
Testing
Run FFI tests:
cargo test
This includes:
- Lifecycle tests
- Null safety tests
- Dimension mismatch tests
- Thread-local error tests
- UTF-8 validation tests
Errors
Every error from Chassis says what was wrong, with the value that was wrong, and on a line
starting help: what to do instead:
The index at vectors.chassis holds vectors of 768 dimensions, not 1536
help: open it with 768 dimensions, or create a new index at another path
Each also has a kind, the same in every language, for a program to act on: Error::kind() in
Rust, chassis_last_error_code() in C, and the exception’s class in Python. Each Python class is
also the built-in exception it is a kind of, so except ValueError catches a bad argument
(ADR-0020).
| Kind | Rust ErrorKind | C code | Python exception |
|---|---|---|---|
| Invalid argument | InvalidArgument | CHASSIS_ERROR_INVALID_ARGUMENT (1) | InvalidArgumentError (ValueError), or TypeError |
| Dimension mismatch | DimensionMismatch | CHASSIS_ERROR_DIMENSION_MISMATCH (2) | DimensionMismatchError (ValueError) |
| Options mismatch | OptionsMismatch | CHASSIS_ERROR_OPTIONS_MISMATCH (3) | OptionsMismatchError (ValueError) |
| Not found | NotFound | CHASSIS_ERROR_NOT_FOUND (4) | IndexNotFoundError (FileNotFoundError) |
| Not an index | NotAnIndex | CHASSIS_ERROR_NOT_AN_INDEX (5) | NotAnIndexError (ValueError) |
| Id in use | IdInUse | CHASSIS_ERROR_ID_IN_USE (6) | IdInUseError (ValueError) |
| Locked | Locked | CHASSIS_ERROR_LOCKED (7) | IndexLockedError |
| Read-only | ReadOnly | CHASSIS_ERROR_READ_ONLY (8) | ReadOnlyError |
| Corrupt | Corrupt | CHASSIS_ERROR_CORRUPT (9) | CorruptIndexError |
| Full | Full | CHASSIS_ERROR_FULL (10) | IndexFullError |
| Input or output | Io | CHASSIS_ERROR_IO (11) | InvalidPathError (OSError) |
| Other | Other | CHASSIS_ERROR_OTHER (12) | ChassisError |
Every Python class is a ChassisError. In C, a call that succeeds sets the code back to
CHASSIS_OK (0).
Invalid argument
An argument is out of range or malformed. The message names it and its value.
- Dimensions are 1 to 4,096: the length of the vectors the index will hold.
max_connectionsis 2 to 32,767; 16 suits most indexes.kis at least 1. In Python it is anint;5.0is aTypeError.- A vector or query with NaN or infinity is refused, at every metric and precision, with the component’s position. NaN usually comes from dividing by zero, as in normalizing a zero vector.
- A zero vector in a cosine index has no direction, so it can’t be compared by angle.
- A component of 65,520 or more in magnitude doesn’t fit half precision, which holds up to 65,504. Scale the vectors down, or use full precision.
- Ids are integers from 0 to 2⁶⁴ − 2. In Python a string id is a
TypeError: keep a dict from your own keys to integer ids. - A path that is a directory: give the path of a file in it.
- In Python, an unknown option is a
TypeErrorthat names the option it was likely meant to be, including other libraries’ names for them (efisef_search,Mismax_connections).metrictakes any case, and"l2"for"euclidean";precisiontakes"float16"and"f16"for"half". There is no inner-product metric: for vectors of length 1, cosine orders results the same way.
Dimension mismatch
A vector or query has another number of components than the index’s vectors, or the file holds vectors of other dimensions than it was opened with. The usual cause is a vector made by another embedding model than the index’s.
- Python, a 2-D array where one vector belongs:
vector[0]orvector.ravel()for one row,add_batchto add several, and onesearchcall per query. - A batch is its vectors back to back,
dimensionsfloats each: a(count, dimensions)array in Python,dimequal to the index’s in C.
Options mismatch
The file was created with another metric or precision than the one asked for. Both are fixed when the index is created, and a writer has to name the same ones again. A reader takes them from the file. Open the index with the ones it was created with, or create a new index at another path.
Not found
There is no index at the path a reader was given, or the directory of a path a writer was given doesn’t exist. A reader opens an index that exists: create it by opening it as a writer first.
Not an index
The file at the path is not a Chassis index. Chassis never writes to such a file. Check the path; to create a new index, give a path where no file exists yet.
Id in use
add_with_id (or ids in Python) gave an id that a live vector already has. Delete that vector
first to replace it, or use another id. Within one batch, ids have to differ.
Locked
Another writer has the index open, in this process or another. An index has one writer at a
time. Close the other writer first, or open a reader (IndexReader, chassis_open_reader,
read_only=True), which searches while the writer writes.
Read-only
A reader was asked to add, delete, flush or compact. Open the index as a writer to change it.
Corrupt
The file is damaged: its header or regions don’t add up. Restore it from a backup, or rebuild it
from its vectors. A crash or power loss doesn’t do this (the index recovers to its last
flush()); a disk error or another program writing the file can.
Full
The index can hold no more: 4,294,967,295 vectors, or no room for another region in its tables. Compact it to drop deleted vectors, or split the vectors across indexes.
Input or output
The operating system refused: no permission, a read-only or full disk, a failed read or write. The message ends with the system’s own words. An earlier flush that failed makes later ones refuse too, since what reached the disk is unknown: open the index again.
Architecture Overview
Chassis is a high-performance, embedded vector database built on three foundational architectural principles:
Core Design Pillars
1. Memory-Mapped Storage (ADR-0001)
Chassis uses memory-mapped I/O (mmap) as its exclusive persistence mechanism, providing:
- Zero-copy access: Direct pointer arithmetic to vectors without heap allocation
- Instant startup: Opening 100GB+ indices in milliseconds (only virtual address space is mapped)
- OS-managed caching: Delegates page management to the kernel’s VMM
- Burst-friendly durability: Memory-speed writes with async kernel flushes
Trade-off: Requires unsafe Rust and careful lifetime management. Since file format 3 the file
grows by appending regions that are each mapped once, so growth never invalidates a mapping
(ADR-0008).
2. Sequential Construction (ADR-0002)
The graph enforces strict monotonic node insertion (0, 1, 2, …):
Invariant: A node with ID N may only link to neighbors M where M < N
Benefits:
- Zero-check search path (no existence validation)
- Crash-safe by design (no dangling forward pointers)
- Deterministic O(1) addressing: a slot’s segment and index follow from its number
Trade-off: Limits parallel construction without a merge phase.
3. Single-Writer Concurrency (ADR-0003)
Chassis implements a SWMR (Single-Writer, Multi-Reader) model:
- Writers:
&mut selfborrow, one writer at a time - Readers: Concurrent
&selfsearches from many threads of the same process - Other processes: Writers are kept out by an exclusive file lock; any number of
IndexReaders search without a lock, each search on the writer’s newest flush (ADR-0008, decision 5)
Benefits:
- Lock-free search (no mutex acquisition overhead)
- No data races between threads
Trade-off: Serialized mutation path.
System Architecture
┌─────────────────────────────────────────────────────────────┐
│ Chassis Application Layer │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────────┐ ┌──────────────────┐ │
│ │ HNSW Graph │ │ Storage Layer │ │
│ │ - Search │◄────────┤ - Vectors │ │
│ │ - Linking │ │ - Mmap Manager │ │
│ │ - Pruning │ │ - Zero-copy │ │
│ └──────────────────┘ └──────────────────┘ │
│ │
├─────────────────────────────────────────────────────────────┤
│ Memory-Mapped File (Single File) │
│ ┌────────────┬──────────────────────────┬────────────────┐ │
│ │ Header │ Segments: slot headers, │ Upper heap, │ │
│ │ A and B │ vectors, level-0 lists │ table pages │ │
│ └────────────┴──────────────────────────┴────────────────┘ │
└─────────────────────────────────────────────────────────────┘
│
▼
Kernel Page Cache (LRU eviction)
│
▼
Physical Storage (SSD/NVMe)
Performance Characteristics
Measured numbers, the machine they came from and how to reproduce them are in Performance.
Component Responsibilities
Orchestration Layer (lib.rs)
VectorIndex: The public facade. It manages theStorageandHnswGraphinstances, ensuring that all operations follow the Crash Consistency Protocol (e.g., correct write ordering). Slots written after the last flush are reused by the next add.
Storage Layer (storage.rs)
- File lifecycle: Open, migration from formats 1 and 2, exclusive locking
- Slots: Append-only; the file grows by appending segments and heap chunks, and committed regions never move
- Zero-copy reads:
get_vector_slice()returns&[f32]backed by mmap - Durability: Two checksummed header copies; a commit fsyncs data, then writes the other copy
HNSW Graph (hnsw/graph.rs)
- Topology management: Node records and adjacency lists, read and written through
Storage - O(1) addressing: A slot’s segment and index are computed, not looked up
- Persistence: Write-ahead ordering (node → neighbors → header)
- Traversal: Neighbor iteration reads the mmap directly via
neighbors_iter_from_mmap()
Distance Metrics (distance.rs)
- SIMD acceleration: AVX2 (x86_64) and NEON (ARM) intrinsics
- Fallback: Portable scalar implementation
- Optimization: 4-way accumulator unrolling for pipeline saturation
Node Layout (hnsw/node.rs)
- In-memory records: A node’s id, layers and neighbor lists while it is linked
- Format 2 records: The fixed-size byte layout of format 2, read only to migrate old files
Linking (hnsw/link.rs)
- Bidirectional edges: Forward (A→B) and backward (B→A) link maintenance
- Diversity heuristic: Heuristic 2 over all
ef_constructioncandidates (ADR-0004) - Crash consistency: Ordered write sequence (ADR-0005, see its amendment)
Search (hnsw/search.rs)
- Dense visited filter: O(1) array access instead of HashSet hashing
- Allocations: Every layer search allocates a
node_count-bit visited set; layer 0 also allocates two heaps and the resultVec - NaN-safe ordering: a node and its squared distance packed into one integer, NaN past every distance (ADR-0014)
Key Invariants
- Node ID density: IDs must be 0, 1, 2, …, N without gaps
- Forward links validity: All neighbor IDs < current
node_count - Mmap stability: Mappings never move; a reference into one lives as long as its borrow
- Write ordering: Node → backward links → header (crash safety)
- Alignment: All offsets and sizes are 8-byte aligned
File Format
Offset Content
─────────────────────────────────────────────────────────
0 Header copy A
64 KiB Header copy B
128 KiB Live page, for readers in other processes
192 KiB Regions: segments, heap chunks, table pages (64 KiB aligned)
See File Format for detailed layout specifications.
Crash Consistency Model
Chassis guarantees structural integrity without Write-Ahead Logging (ADR-0005):
- Ghost nodes: Slots written but not committed (slot >= header count) are ignored and reused
- One-way edges: Incomplete backward links are legal in HNSW and don’t break search
- Header authority: the header’s slot count is what is committed; readers in other processes route past it but never return a slot at or past it
- Stale backlinks: Links to rolled-back nodes are skipped by search until their slot is reused, and linking skips a stale link on a layer the reusing node isn’t on
After a crash, reopening keeps every add and delete up to the last flush() and drops later ones; graph edges changed after that flush may be partly lost, which can lower recall. Process kills are tested (chassis-core/tests/crash_tests.rs), and power loss is simulated (chassis-core/src/power_loss.rs), though not on real hardware.
Recovery: Opening the index after a crash reads the newest valid header copy. Only a crash during a flush with deletes leaves work: clearing that flush’s delete marks, one pass over the slot headers.
Design Trade-offs Summary
| Decision | Benefit | Cost |
|---|---|---|
| Memory-mapping | ns-latency reads, instant startup | unsafe Rust, SIGBUS risk |
| Sequential construction | Zero-check search, crash safety | No parallel building |
| SWMR concurrency | Lock-free reads, no races | Serialized writes |
| Level-0 records in slots, upper layers in a heap | Records 13× smaller than format 2’s | One heap lookup for the few nodes above layer 0 |
| Diversity heuristic | Better graph quality | O(N·M) pruning complexity |
Storage Layer
The storage layer (storage.rs) owns the index file: its lock, its regions, and the commit
protocol. The byte layout is in File Format.
Opening a File
Storage::open and VectorIndex::open:
- Open or create the file, and take an exclusive lock on it:
flockon Unix, and on WindowsLockFileExon one byte at offset 2^62, since a lock on the whole file would also block readers’ReadFile. On Unix they then check that the locked file is still the one at the path, since a migration may have replaced it in between. - Initialize an empty file: header copies A and B, fsync, then fsync the directory so power loss can’t take the new file with it.
- Migrate a version 1 or 2 file to version 3.
- Read both header copies, take the newest valid one, map every committed region, and run recovery if a flush with deletes was interrupted.
Growth
Slots fill segments; when a slot needs a segment that doesn’t exist yet, the next region is appended at the header’s file end and mapped. Existing mappings never change, so a reference into the file stays valid while the index grows, and nothing is ever copied or remapped. Regions past the committed file end were left by a flush that never committed, and are reused.
Durability
Writes are not durable until a commit. A commit calls msync on every mapped region, then
sync_all (fsync on Linux, fcntl(F_FULLFSYNC) on macOS, FlushFileBuffers on Windows), then
writes the header copy that does not hold the newest header, and syncs again. If any of those
fails, the storage refuses every later commit: after a failed fsync the OS may already have dropped
the dirty pages, so a retry that succeeds would commit data that is gone. Reopen the index instead.
Concurrency
One writer at a time: a second writer’s open of a locked file returns an error immediately. It
does not block or retry. The lock is released when the Storage is dropped.
Readers (Storage::open_read_only, behind IndexReader) take no lock and map the file read-only.
Before every search a reader takes a snapshot: it copies both header copies a word at a time,
keeps the newest valid one (never an older one than it already used), maps any region the header
or the live page shows, and routes through slots up to the live page’s routing count. Search
returns only slots the header commits and no flush up to its epoch deleted. The writer stores
neighbor lists, delete marks, headers and the live page as atomic words, and rewrites a neighbor
list in place so that an entry in both the old and the new list keeps its slot.
Graph Topology & Construction
Chassis implements a persistent, single-file HNSW (Hierarchical Navigable Small World) graph. Unlike in-memory implementations, the Chassis graph is designed for crash consistency, zero-copy access, and deterministic behavior.
This document outlines the construction policies, persistence model, and neighbor selection algorithms.
1. Construction Policy: Sequential Existence
Chassis enforces a Strict Sequential Linking policy to ensure the graph remains navigable and corruption-free without complex runtime checks.
Invariant: A node with ID N may only link to neighbors with IDs M < N.
Implications
- Safety: Forward links in a
NodeRecordonly reference existing nodes. We never store a pointer to uninitialized memory. - Search Efficiency: Search traversal does not need to check if a neighbor exists. If the ID is in the neighbor list, the data is guaranteed to be on disk.
- Crash Recovery: There are no “orphaned” forward edges pointing to the future.
Usage Pattern
The graph must be built by inserting nodes in dense, monotonic order (0, 1, 2…).
#![allow(unused)]
fn main() {
// WRONG: Cannot link to non-existent nodes
// Error: nodes 1, 2, 3 don't exist yet
graph.link_node_bidirectional(0, 1, &[vec![1, 2, 3]])?;
// CORRECT: Sequential construction
graph.link_node_bidirectional(0, 1, &[vec![]])?; // Node 0: no neighbors yet
graph.link_node_bidirectional(1, 1, &[vec![0]])?; // Node 1: links to 0
graph.link_node_bidirectional(2, 1, &[vec![0, 1]])?; // Node 2: links to 0, 1
}
2. Crash Consistency Model
After a crash, reopening keeps every add and delete up to the last flush() and drops later ones; graph edges changed after that flush may be partly lost, which can lower recall. Process kills are tested by
chassis-core/tests/crash_tests.rs, and power loss is simulated by chassis-core/src/power_loss.rs
(not on real hardware). The write order below protects against a process crash only, because the OS
writes mmap pages back in any order; under power loss the fsyncs in flush() are what order writes. See the
ADR-0005 amendment.
The Atomic Write Sequence
- Step A (Persist Node): The new node
Ais written to disk with its forward links (A→Neighbors). - Step B (Update Neighbors): We iterate through the neighbors (
B,C…) and update their backward links (B→A). - Step C (Update Header): The global node count and entry point are updated.
Why This Ordering Matters
- After Step A: Node
Aexists on disk but has no incoming edges. It is technically “invisible” to search from the entry point, but the file is valid. - During Step B: Some neighbors point to
A, others don’t. This creates “one-way edges,” which are valid in HNSW and do not break search. - After a crash before
flush():node_countrolls back but backlinks toAmay survive. Search skips them until the next insert reusesA’s slot; after that they are ordinary edges to the new node. If the new node is on fewer layers thanAwas, a surviving link on a higher layer points at a node not on that layer: search finds no neighbors there, and linking adds no backlink to it. Edges pruned to make room forA’s backlinks stay lost.
3. Neighbor Selection (Diversity Heuristic)
To prevent “cluster collapse” and ensure the graph maintains its Small World properties, Chassis implements a variant of Heuristic 2 from the HNSW paper.
The Selection Algorithm
When a neighbor list exceeds its capacity (M or M0), we prune it using Diversity Selection:
- Candidate Set: We gather the current neighbors plus the new node.
- Diversity Check: We keep a candidate
Conly if it is closer to the base nodeBthan it is to any already-selected neighbor.
- Goal: Prioritize neighbors in different directions rather than just the closest ones.
- Starvation Fallback: If diversity pruning is too aggressive and yields fewer than
M/2neighbors, we fill the remaining slots with the nearest remaining candidates. This ensures robust connectivity even in highly clustered data.
A new node chooses from all of its ef_construction search candidates. Each candidate is compared
with each neighbor already kept at most once, so there is nothing to cache. An earlier lazy distance
cache never got a hit and limited selection to 33 candidates; it was removed (see the
ADR-0004 amendment).
4. Idempotency & Retry Safety
The link_node_bidirectional operation is idempotent. It is safe to retry the operation if a crash occurs mid-update.
- Duplicate Detection: Before adding a backward link (
B→A), the engine checks ifBalready links toA. - No Side Effects: Retrying a completed operation results in zero disk writes.
5. Edge Case Policies
| Case | Policy | Rationale |
|---|---|---|
| Self-Links | Filtered | A node linking to itself wastes capacity and offers no search value. |
| Invalid IDs | Error | Linking to an ID >= node_count returns an explicit error. |
| Layer Independence | Enforced | Neighbor lists are processed independently per layer; candidates are not shared across layers. |
| Identical Vectors | Fallback | If all vectors are identical, the diversity heuristic fails; the Starvation Fallback ensures connectivity. |
File Format
A Chassis file is one file in format version 3 (ADR-0008), or version 4 if it keeps its vectors in half precision (ADR-0018). It holds two header copies, then append-only regions: segments of slots, heap chunks of upper-layer neighbor lists, and table pages that list where the others start. Each region is mapped once, and committed bytes never move.
Opening a version 1 or 2 file migrates it to version 3 (see Migration).
Top-Level Layout
| Offset | Size | Contents |
|---|---|---|
0 | 64 KiB | Header copy A |
64 KiB | 64 KiB | Header copy B |
128 KiB | 64 KiB | Live page, for readers in other processes |
192 KiB | rest | Regions, in the order they were allocated |
The header copies sit 64 KiB apart so they never share a page. Every region starts at a multiple of 64 KiB, the Windows mapping granularity and a multiple of every supported page size, so each one can be mapped on its own. The file only grows by appending a region, and never shrinks.
Header
Each copy is a header of 144 + 8 × (segment table pages + heap table pages) bytes, little-endian.
A later write version may add fields after these; the length covers them.
The valid copy with the higher sequence number is current.
| Offset | Size | Field | Description |
|---|---|---|---|
| 0 | 8 | Magic | CHASSIS\0 in every format version |
| 8 | 4 | Read version | Oldest format a reader must understand: 3, or 4 in half precision |
| 12 | 4 | Write version | Oldest format a writer must understand: 3, or 4 in half precision |
| 16 | 8 | Checksum | xxh3-64 of the header’s bytes with this field zeroed |
| 24 | 8 | Sequence | Increases by one with every header written |
| 32 | 4 | Length | Header length in bytes, table page lists included |
| 36 | 4 | Dimensions | Components per vector, 1 to 4096 |
| 40 | 2 | M | Neighbors per upper-layer list |
| 42 | 2 | M0 | Neighbors per layer-0 list |
| 44 | 1 | Max layers | Layers a node can belong to |
| 45 | 1 | Metric | 0: Euclidean; 1: cosine, over vectors stored at unit length |
| 46 | 1 | Flags | Bit 0: some id differs from its slot. Bit 1: superseded, a compacted copy is replacing this file at its path (ADR-0011) |
| 47 | 1 | Table page log2 | Entries per table page, as a power of two |
| 48 | 1 | Segment base log2 | Slots in the first segment, as a power of two |
| 49 | 1 | Doubling segments | Segments that double in size before the size stays constant |
| 50 | 1 | Heap base log2 | Bytes in the first heap chunk, as a power of two |
| 51 | 1 | Doubling chunks | Heap chunks that double in size before the size stays constant |
| 52 | 1 | Precision | 0: each component is an f32; 1: an IEEE 754 16-bit float, in a file of version 4 |
| 56 | 8 | Count | Committed slots |
| 64 | 8 | Entry point | Slot of the graph’s entry point, u64::MAX if empty |
| 72 | 4 | Max layer | Highest layer in the graph |
| 80 | 8 | Epoch | Last flush that committed deletes |
| 88 | 8 | Pending epoch | Nonzero while a flush with deletes is in progress |
| 96 | 8 | Deleted count | Deleted slots |
| 104 | 8 | File end | End of the last committed region |
| 112 | 4 | Segments | Committed segments |
| 116 | 4 | Heap chunks | Committed heap chunks |
| 120 | 8 | Heap used | Next free heap position, chunk << 32 | byte offset |
| 128 | 4 | Segment table pages | Number of segment table pages |
| 132 | 4 | Heap table pages | Number of heap table pages |
| 136 | 8 each | Table page offsets | Segment table pages, then heap table pages |
| after the tables | 8 | Next id | One past the largest id used before the last compaction, which removed the deleted slots that showed it; 0 if never compacted. Absent from headers written before it existed, which read as 0 |
A release checks the read version before the checksum, so a newer layout is reported as too new,
not as corrupt. A newer write version only stops writers: IndexReader reads the file and ignores
the bytes after the next id. Releases up to 0.6.3 read bytes 8–11 as their version and refuse
anything above 2.
A file in half precision is version 4 to readers and writers alike, so that a release from before
half precision refuses it and doesn’t read its vectors as f32s. A file in full precision is
written as version 3 by every release, with byte 52 zero as it always was.
Live Page
What the writer has added since its last commit, so readers in other processes can route through it. Every field is a little-endian u64. The counts and the routing count are written with release ordering after the data, offsets and table entries they cover. It is not durable: a writer resets it to its committed state when it opens the file, and a reader that opens the file before any writer, after a crash, uses it as found, treating records it can’t read past the committed count as having no neighbors.
| Offset in page | Field |
|---|---|
| 0 | Routing count: slots whose nodes and backlinks are written |
| 8, 16, 24, 32 | Segments, heap chunks, segment table pages, heap table pages |
| 64 | Segment table page offsets (512 entries) |
| 4,160 | Heap table page offsets (512 entries) |
Segments
Segment k holds 2^(base + min(k, K)) slots, where base is the segment base log2 and K the
number of doubling segments. A new file uses base = 10 and the largest K whose segment fits in
256 MiB, so every segment after the first K has the same C = 2^(base + K) slots. With
B = C − 2^base, the slots in all doubling segments together:
- slot
s < Bis in segment⌊log2(s + 2^base)⌋ − base, at indexs + 2^base − 2^⌊log2(s + 2^base)⌋; - slot
s ≥ Bis in segmentK + ((s − B) >> log2 C), at index(s − B) & (C − 1).
A segment of n slots holds three arrays, each starting at a multiple of 64 bytes:
| Array | Per slot | Contents |
|---|---|---|
| Slot headers | 24 bytes | id (u64), metadata reference (u64, 0), deleted epoch (u64, 0 while live) |
| Vectors | dims × 4 bytes, or dims × 2 in half precision | the vector, little-endian |
| Level-0 records | 8 + 4 × M0 bytes | head (u64), then M0 neighbor slots (u32) |
A record’s head holds the node’s layer count in bits 0–7 and, for a node above layer 0, the heap
position of its upper-layer lists in bits 8–63. Empty neighbor entries are 0xFFFFFFFF. A slot is
deleted once its deleted epoch is nonzero; only a flush writes it.
Upper Heap
Heap chunk c is 2^(heap base + min(c, doubling chunks)) bytes; a new file uses 64 KiB chunks
that double up to 256 MiB. A node on L layers has one heap entry of L − 1 lists of M neighbor
slots (u32), for layers 1 to L − 1. An entry never straddles two chunks.
Table Pages
A table page holds 2^(table page log2) file offsets (u64), 8,192 in a new file, so a page is
64 KiB. Entry i of the segment table is in page i >> log2, at index i & (2^log2 − 1); the heap
table works the same way. The header lists up to 512 pages per table, enough for 2^32 slots.
Commits and Recovery
A flush writes data in place, fsyncs, then writes the header copy that does not hold the newest header and fsyncs again. Slots, regions and table entries past the committed counts are leftovers of a flush that never committed: the next writer reuses them, and until then readers may route through them but never return them.
A flush with deletes commits through an intent header first: the previous commit with
pending epoch = epoch + 1, then the delete marks, then the commit header in the other copy, with
an fsync after each step. Opening a file whose newest header has a pending epoch clears every
deleted epoch above the header’s epoch in committed slots, then writes a clean header. Process
kills are tested in chassis-core/tests/crash_tests.rs; power loss is simulated in
chassis-core/src/power_loss.rs, including crashes inside recovery.
Size Example
10,000 vectors with 768 dimensions and default parameters take 3,232 bytes per slot (24-byte slot header, 3,072-byte vector, 136-byte level-0 record) plus about 4 bytes of upper heap. The first four segments hold 1,024, 2,048, 4,096 and 8,192 slots, so the file is about 50 MB, of which 32 MB is written. The unused end of the last segment is never written, but whether the file system keeps it sparse varies: on APFS, a real build of this example allocated 33.6 MB, while unused ranges of 16 MiB or less next to written data were sometimes allocated in full. ext4 is untested.
The undo file
Adds change the neighbor lists of committed nodes in place before they are committed themselves.
Before a committed node’s lists first change after a flush, they are appended to <name>.undo,
and a writer that opens the index after a crash writes them back (ADR-0012). The file exists
only between a flush and the next one that follows adds, or after a crash; a flush removes it.
An index moved or copied while the file exists has to take it along.
After the 8 bytes CHSUNDO1, each entry is the slot (u32), its layer count (u8), then per layer
a count (u16) and that many neighbor ids (u32), then an xxh3-64 of the entry, seeded with a hash
of the committed vector count, entry point, top layer, delete epoch, deleted count and id mark.
Entries saved from another commit fail the checksum and are ignored.
Migration
Opening a version 1 or 2 file converts it. Chassis takes the original’s lock, writes the committed
state into <name>.migrating: slots below the graph’s node count, and only the delete marks the
graph header committed. It converts neighbor ids from u64 to u32 and moves upper layers into the
heap, then fsyncs the new file and renames it over the original, and fsyncs the directory. The
graph is converted, not rebuilt, so search results are unchanged. A failed or interrupted migration
leaves the original in place.
Validation
On open, Chassis checks that:
- one header copy has the magic, versions it understands and a valid checksum; two valid copies with the same sequence number are corruption;
- the dimensions match, and the graph parameters and geometry are possible;
- the counts fit the tables, and every committed region lies inside the file;
VectorIndex::openonly: the graph parameters match the requestedmax_connections, and a file with vectors has a graph.
A failed check returns an error and never modifies the file.
Stability
Format version 3 is the format ADR-0008 proposes to freeze. Until that ADR is accepted, it may still change.
Performance Guide
Where Chassis stands
Measured from 2026-10-08 to 2026-10-10, one thread searching, on GitHub’s server runners (AMD EPYC Zen 3, 4 and 5, Intel Xeon, Arm Neoverse-N2) and an Apple M5. The decision records hold the details and the commands.
Against hnswlib, at the same recall: queries a second, Chassis over hnswlib (ADR-0017, ADR-0018).
| Vectors | x86 | Neoverse-N2 |
|---|---|---|
| 128 dims × 1M | 1.2–2.6× | 3.1–4.4× |
| 960 dims × 200k | 0.86–1.05×; in half precision 1.0–2.1× | 1.65–1.77×; half 2.3–2.5× |
| 1,536 dims × 99k | 0.82–1.13×; in half precision 1.0–1.7× | 1.85–2.33×; half 2.45–2.93× |
These came before distances were computed in groups (ADR-0021), which made searches over vectors of a kilobyte or more another 11 to 60% faster out of cache.
Through Python: each engine through its Python package on one thread, every query in one call
(Chassis’s search_batch), queries a second for 0.95 recall, Chassis over the other engine
(2026-10-10, one run, one machine per CPU). With one call per query instead, Chassis lost a fifth to
a third of its speed from Rust, 15 to 38 µs a query; in one call it is within 5% of it.
| Vectors | Over hnswlib | Over FAISS’s HNSW |
|---|---|---|
| 128 dims × 1M | Zen 3 1.13×, Xeon 1.33×, N2 3.46× | Zen 3 0.98×, Xeon 1.22×, N2 1.69× |
| 128 dims × 1M, half | Zen 3 1.61×, Xeon 2.01×, N2 3.63× | Zen 3 1.40×, Xeon 1.85×, N2 1.77× |
| 1,536 dims × 99k | Zen 4 1.26×, Xeon 1.05×, N2 2.92× | Zen 4 0.78×, Xeon 0.95×, N2 1.09× |
| 1,536 dims × 99k, half | Zen 4 1.91×, Xeon 1.55×, N2 2.92× | Zen 4 1.19×, Xeon 1.40×, N2 1.09× |
From a cold start (ADR-0019, Apple M5, 99,000 × 1,536 dims, the file not
in memory): the first result came 0.11 s after the process started, where hnswlib, which loads its
file first, gave its first at 0.5 to 0.8 s. With warm(), the hundredth result came at 0.37 s
instead of 1.9 s.
The sections below are earlier measurements, from 2026-10-03 to 2026-10-05 on the M5 and mostly of file format 2: they show how Chassis got here, not where it is now.
Measured on 2026-10-03 to 2026-10-05 on an Apple M5 (10 cores, 24 GiB) running macOS 26.6 on APFS, with rustc 1.99.0 and release builds, on a machine that was also running other applications. Numbers on other machines will differ, so the commands to reproduce them are at the end. Search and build numbers below are for the code after the 33-candidate cap was removed (ADR-0004 amendment).
Against hnswlib and usearch
From bench/ann: the same settings for every engine (M 16, ef_construction 200), one thread for
building and searching, 1,000 queries per dataset, recall@10 against the datasets’ ground truth.
Chassis runs from Rust; hnswlib 0.8.0 and usearch 2.26.3 run through their Python batch APIs with
one thread. usearch built exactly the same graph as hnswlib at these settings (identical results for
every query), so it differs only in speed.
| Dataset | Engine | Build | ef 64: recall / QPS | ef 256: recall / QPS | ef 512: recall / QPS | File |
|---|---|---|---|---|---|---|
| SIFT-1M, 128 dims | Chassis | 567 s | 0.969 / 3,741 | 0.998 / 1,433 | 0.999 / 964 | 3,379 MB |
| hnswlib | 769 s | 0.960 / 3,904 | 0.997 / 1,098 | 0.999 / 633 | 661 MB | |
| usearch | 938 s | 0.959 / 2,143 | 0.997 / 642 | 0.999 / 361 | 661 MB | |
| GloVe, 1.18M × 100 | Chassis | 1,430 s | 0.788 / 3,419 | 0.897 / 1,328 | 0.934 / 678 | 3,289 MB |
| hnswlib | 988 s | 0.763 / 2,780 | 0.887 / 1,009 | 0.930 / 567 | 649 MB | |
| usearch | 1,536 s | 0.763 / 2,027 | 0.887 / 588 | 0.930 / 264 | 649 MB | |
| dbpedia, 99k × 1536 (OpenAI) | Chassis | 420 s | 0.978 / 918 | 0.998 / 288 | 1.000 / 158 | 982 MB |
| hnswlib | 673 s | 0.970 / 478 | 0.996 / 154 | 0.999 / 81 | 623 MB | |
| usearch | 607 s | 0.970 / 518 | 0.996 / 140 | 0.999 / 70 | 623 MB |
What these numbers do and don’t show:
- One thread is not how builds usually run. On every core (see Parallel builds), Chassis and hnswlib build 2.4 to 5.2 times faster, and Chassis’s lead on SIFT holds.
- This is an ARM machine. hnswlib’s SIMD distance code only targets x86, so here it runs compiler-vectorized loops (about 264 ns per 1,536-dim distance, against 149 ns for Chassis’s NEON code). On x86 the gap at 1,536 dims may close. usearch has ARM SIMD and was still slower here.
- Chassis searches warm memory-mapped pages. The harness runs an untimed pass after each open; a cold first pass after opening was about half as fast on SIFT at ef 16.
- These Chassis rows are file format 2. Its files and memory were much larger (peak memory about 4.2 GB against 0.8–0.9 GB on SIFT and GloVe), from fixed 2,192-byte node records, and GloVe’s build spent 568 of its 1,430 s in the kernel, moving the graph within the file and remapping it as it grew. Format 3 removes both; see Format 3.
- Removing the 33-candidate cap made dbpedia’s build 19% slower (420 s against 352 s), in
exchange for higher recall at every
ef; on SIFT and the 20k example, build time didn’t increase.
End to End
From
examples/recall.rs:
uniform random vectors in [-1, 1], default IndexOptions (max_connections 16, ef_construction
200), build time including the final flush(), 200 queries, recall@10 against brute force.
| Vectors | Dims | Build | ef_search | Recall@10 | Mean search |
|---|---|---|---|---|---|
| 20,000 | 128 | 7.5–7.6 s (2 runs) | 50 | 0.58 | 112–132 µs |
| 200 | 0.90 | 325–341 µs | |||
| 20,000 | 1536 | 92.1 s | 50 | 0.37 | 1.2 ms |
| 200 | 0.69 | 3.6 ms |
Uniform random vectors are close to the worst case for HNSW. Real embeddings usually reach higher
recall at the same settings, so measure on your own data before choosing ef_search.
Deletes
Same 20,000 × 128 index with part of it deleted, from the last lines of examples/recall.rs
(ef_search 50, two runs on 2026-10-05 while the machine was busy, so latency is relative to the
same run’s undeleted search, and noisy):
| Deleted | Recall@10 (live vectors) | Search time vs. none deleted | Flush with these deletes |
|---|---|---|---|
| 0% | 0.58 | 1× | 3.9 ms |
| 10% | 0.59–0.60 | 0.9–1.2× | 17 ms (2,000 deletes) |
| 50% | 0.72–0.74 | 1.1–1.8× | 30–42 ms (8,000 deletes) |
Search still walks through deleted vectors to keep the graph connected, so it slows down as deletes accumulate; recall rises because it explores further to collect enough live results. Deleted vectors keep their space until the index is rebuilt.
Storage
From storage_bench, 768-dimensional vectors, in a temp dir on APFS.
| Operation | Time |
|---|---|
| Insert, no flush | 17 µs (12–22 µs) |
| Insert, then flush | 3.9 ms |
| 10 inserts, then one flush | 4.1 ms |
| 100 inserts, then one flush | 4.3 ms |
| 1,000 inserts, then one flush | 7.9 ms |
| Read a cached vector | 85 ns |
| Read a vector after reopening | 79 µs |
| Read 1,000 vectors in order | 118 µs |
| Grow an empty file to 1,000 vectors | 6.8 ms |
flush() costs milliseconds because it waits for the disk (F_FULLFSYNC on macOS, fsync
elsewhere). Insert in batches and flush once per batch. A temp dir on tmpfs makes fsync free, so
benchmarks run there understate this cost.
Distance Kernels
From distance_bench, NEON on this machine (AVX2 on x86_64).
| Dims | SIMD | Scalar | Speedup |
|---|---|---|---|
| 64 | 8.1 ns | 21.5 ns | 2.7x |
| 128 | 15.3 ns | 59.1 ns | 3.9x |
| 384 | 35.5 ns | 239 ns | 6.7x |
| 768 | 76.4 ns | 613 ns | 8.0x |
| 1536 | 149 ns | 1.39 µs | 9.4x |
| 3072 | 315 ns | 2.88 µs | 9.1x |
SIMD throughput is about 10 billion elements per second at 384 dimensions and up.
Other Benchmarks
search_bench, link_bench, graph_io_bench and hnsw_node_bench are microbenchmarks.
search_bench searches hand-wired graphs (single-layer, except the greedy-descent bench) over
vectors with at most 16 non-zero dimensions, not graphs built by VectorIndex, so its latencies are
far below real search. Use examples/recall.rs for end-to-end numbers.
Index Size
Each slot takes a 24-byte slot header, its vector, and a level-0 record of 8 + 4 × M0 bytes (136
with the defaults). The few nodes above layer 0 add 4 × M bytes per extra layer in the upper heap,
about 4 bytes per vector on average. For 1M vectors with 768 dimensions that is 3.07 GB of vectors
and 0.16 GB of everything else.
Segments double in size up to 256 MiB, so the file can be up to one segment larger than the data written. That unused end is never written; whether it stays sparse depends on the file system (on APFS, ranges above about 16 MiB did and smaller ones sometimes didn’t; ext4 is untested). Nothing is ever copied to grow the file.
Format 3
ADR-0008 “Implementation Status” has the full results. In short, on the same graphs converted from format 2:
- Search returns identical results, ids and distances, for every query at every ef on SIFT-1M and dbpedia, and is 1.5–12% faster (median paired ratio per ef), from smaller records and a smaller working set. Re-measured after the reader changes to the search path: no slower.
- Files are 4.7× smaller on SIFT-1M (713 MB against 3,379 MB) and 1.2× on dbpedia (821 MB against 982 MB). Converting SIFT-1M took 9.4 s.
- Opening a 10M-slot file takes under 1 ms; it only maps the file, so pages load on first touch. An index with its own ids builds its id table on the first add or delete in each process: at 10M ids, a median of 0.47 s with the file in the page cache and 0.91 s right after copying it, on a machine with other load. Filling the hash table is over 90% of that.
Built from scratch on format 3 (one thread, same settings as the table above, with other applications running, so build times are indicative):
| Dataset | Build | In the kernel | Peak memory | File | Recall at ef 64 / 256 / 512 |
|---|---|---|---|---|---|
| SIFT-1M | 527 s | 10.6 s | 1.09 GB | 713 MB | 0.968 / 0.998 / 0.999 |
| dbpedia | 431 s | 6.4 s | 1.22 GB | 821 MB | 0.978 / 0.998 / 1.000 |
Peak memory includes the training vectors the harness holds (0.51 GB for SIFT, 0.61 GB for dbpedia). Format 2 peaked at about 4.2 GB on SIFT, and GloVe’s format 2 build spent 568 s in the kernel.
Parallel builds
Measured on 2026-10-07 on the same M5, which has 4 performance and 6 efficiency cores and was in
Low Power Mode, with other applications running. One build at a time, M 16, ef_construction
200; “every core” is Chassis’s add_batch and hnswlib’s num_threads=-1. Search is one thread.
| Dataset | Engine | One thread | Every core | Speedup | ef 64, every core: recall / QPS |
|---|---|---|---|---|---|
| SIFT-1M | Chassis | 545 s | 110 s | 5.0× | 0.968 / 4,111 |
| hnswlib | 682 s | 131 s | 5.2× | 0.959 / 2,878 | |
| dbpedia, 99k × 1536 | Chassis | 417 s | 176 s | 2.4× | 0.978 / 841 |
| hnswlib | 596 s | 163 s | 3.7× | 0.970 / 481 |
Graphs built on every core reach the same recall as one-thread builds of either engine (within
0.002 from ef 32 up, and 0.007 below). Their QPS varied by up to 40% either way between single
passes, so these runs don’t compare search speed. An earlier parallel dbpedia run the same day took 159 s for Chassis and 158 s
for hnswlib, so builds vary by about 10% between runs. The 1,536-dimension build scales less for
both engines; ADR-0010 has the details.
Reproduce
cargo run --release --example recall -- 20000 128
cargo bench --bench storage_bench
cargo bench --bench distance_bench
# Against hnswlib and usearch (downloads about 1.9 GB of datasets)
pip install numpy h5py pyarrow hnswlib usearch
python bench/ann/prepare.py
cargo run --release --example ann -- bench/ann/data sift-128 # add `batch` for every core
python bench/ann/reference_bench.py hnswlib bench/ann/data sift-128 # add `0` for every core
python bench/ann/reference_bench.py usearch bench/ann/data sift-128
Criterion reports go to target/criterion/.
ADR-0001: Memory-Mapped Storage Strategy
Date: 2026-01-18
Status: Accepted
Context
Chassis is designed to operate across a wide range of environments, from resource-constrained edge devices (e.g., Raspberry Pi) to high-memory servers. A core requirement is the ability to store and search vector datasets that may exceed available physical RAM.
Traditional approaches present fundamental limitations:
- Standard file I/O (
read/write) incurs kernel-to-user copying overhead and deserialization costs, resulting in microsecond-scale access latency. - Loading the full dataset into heap-allocated structures (
Vec<f32>) causes long startup times and introduces hard memory ceilings, often leading to OOM failures. - Copy-based access patterns prevent the system from fully exploiting CPU cache locality during distance computation.
The system therefore requires a persistence mechanism that:
- supports datasets larger than RAM,
- provides pointer-dereference-level access latency (~ns),
- minimizes startup time,
- and integrates cleanly with zero-copy search and linking logic.
Decision
Chassis will use memory-mapped I/O (mmap) as the exclusive mechanism for data access and persistence.
The design has the following properties:
-
Single File Backing The entire index—vectors, graph topology, and metadata—is stored in a single contiguous file on disk.
-
OS-Managed Caching Page caching, eviction (LRU), and write-back behavior are delegated entirely to the operating system’s virtual memory manager (VMM).
-
Zero-Copy Read Path All reads are performed by casting raw pointers (
*const u8) into typed views (&[f32],&NodeRecord) directly within the mapped address space. No intermediate buffers or heap allocations are permitted on the read path. (Amended 2026-10-03: search allocates a visited set and heaps per query, andread_node_recordreturns an owned record. Vector and neighbor reads themselves do not allocate.) -
Cross-Platform Abstraction The implementation relies on the
memmap2crate, providing a safe and portable abstraction overmmap(POSIX) andCreateFileMapping(Windows).
Consequences
Positive
-
Zero-Copy Access Retrieving a vector becomes pure pointer arithmetic. On a cache hit, access latency is on the order of a few nanoseconds, enabling high arithmetic density during distance computation.
-
Instant Startup Opening a large index (e.g., 100GB) completes in milliseconds, as only the virtual address space is mapped; no data is eagerly loaded into RAM.
-
Burst-Friendly Durability Writes occur at memory speed, with dirty pages flushed asynchronously by the OS. Explicit durability boundaries are provided via
commit()(fsync), allowing callers to trade latency for persistence guarantees.
Negative
-
Use of
unsafeRust Memory-mapped access requiresunsafeblocks. Chassis assumes responsibility for validating alignment, bounds, and structural integrity before casting pointers to typed references. -
Non-Standard I/O Failure Modes Hardware-level I/O failures (e.g., disk full, corrupted blocks) may manifest as
SIGBUSrather than recoverableResult::Errvalues. While rare in typical deployments, this requires operational awareness. -
Complex File Growth Semantics The backing file cannot be grown transparently. Expanding capacity requires explicit truncation and re-mapping (
ensure_capacity), which invalidates all previously derived pointers. As a result, references into the mmap must never be held across insertion or growth boundaries.
Compliance
This decision is enforced throughout the codebase:
Storage::get_vector_slicereturns&[f32]backed directly by the memory map, strictly enforcing zero-copy access.Storage::ensure_capacitymanages file growth and re-mapping, ensuring page-aligned (4KB) expansion.- All file access in
chassis-coreis routed exclusively through theStorageabstraction; direct use ofstd::fs::Filefor reads or writes is prohibited.
ADR-0002: Sequential Graph Construction Policy
Date: 2026-01-24
Status: Accepted
Context
Constructing an HNSW graph requires establishing edges between nodes. A fundamental architectural choice arises during index construction: whether a node is permitted to reference neighbors that have not yet been persisted.
Two competing models exist:
Model A: Strict Sequential Construction
A node may link only to neighbors that have already been persisted.
Pros
- Guarantees that every edge resolves to valid, initialized data.
- Enables a fully unchecked, branch-free traversal path.
- Simplifies persistence and crash recovery semantics.
Cons
- Enforces a strict monotonic insertion order (0, 1, 2, …).
- Limits opportunities for parallel graph construction without a merge phase.
Model B: Future References (Non-Sequential)
A node may link to neighbor IDs that do not yet exist on disk.
Pros
- Enables non-sequential or partially parallel construction strategies.
- Allows speculative or distributed graph planning.
Cons
- Creates dangling forward edges pointing to uninitialized or invalid file offsets.
- Requires runtime existence checks during search.
- Complicates crash recovery and persistence guarantees.
This decision directly impacts three critical system dimensions:
- Search latency
- Crash consistency
- Parallelization potential
Decision
Chassis enforces a Strict Sequential Construction Policy (Model A).
Invariant
A node with ID N may only link to neighbors with IDs M < N.
Any attempt to violate this invariant results in filtering or rejection before persistence.
Enforcement Mechanisms
This policy is enforced at multiple layers:
-
Filter-on-Write
link_node_bidirectionalautomatically filters out any neighbor IDs>= self.node_countbefore writing node records to disk. -
Monotonic Insertion
insertrequires that the new node ID is exactly equal to the currentnode_count. Any attempt to skip or reorder IDs results in an error.
Consequences
Positive
Zero-Check Search Path
The hot traversal path (search_layer, neighbors_iter_from_mmap) performs no existence checks.
If a neighbor ID appears in an adjacency list, it is guaranteed to correspond to valid, initialized data on disk. This removes a conditional branch from every edge traversal and improves branch predictor stability.
Not after a crash: backlinks to rolled-back nodes can survive. See the ADR-0005 amendment.
Crash Safety by Design
The system never persists pointers to future file offsets.
In the event of a crash, the graph remains consistent up to the last successfully written node. There are no orphaned edges or forward references requiring repair or validation during recovery.
Backlinks are the exception. See the ADR-0005 amendment.
Deterministic Persistence
Graph construction reduces to a linear, append-only write pattern.
There is no need for a secondary “patching” or resolution phase to fix up forward references, greatly simplifying durability and recovery logic.
Negative
Serialized Construction Bottleneck
Graph construction must proceed in strict ID order (0, 1, 2, …).
This limits parallelization across threads or machines without introducing a separate planning or merge phase, which may be costly.
Testing Rigidity
Unit tests cannot create sparse or non-monotonic graphs (e.g., nodes 0, 10, 100).
Tests must construct intermediate nodes, resulting in slightly more boilerplate and setup code.
Compliance
This decision is enforced throughout the implementation:
-
Insertion Invariant
HnswGraph::insertassertsvector_id == self.node_countin both debug and release builds, returning an error if violated. -
Neighbor Filtering
HnswGraph::link_node_bidirectionalapplies.filter(|&id| id < self.node_count)to all neighbor candidates prior to persistence. -
Safe Traversal
NeighborIteratorassumes all returned IDs are valid and performs no bounds checks against the global node count.
ADR-0003: Single-Writer / Multi-Reader (SWMR) Concurrency
Date: 2026-01-24
Status: Accepted
Context
Chassis is designed as an embedded, in-process storage engine backed by memory-mapped files. It must safely support concurrent access from multiple threads (and potentially multiple processes) without data corruption, while prioritizing low-latency search.
HNSW graph mutation is particularly sensitive to concurrency:
- Race Conditions: Concurrent writers updating adjacency lists can overwrite each other, corrupting graph topology.
- Reader Safety: Readers traversing memory-mapped structures must never observe half-written records, dangling pointers, or invalid node IDs.
- Lock Contention: Fine-grained locking (e.g., per-node mutexes) introduces memory overhead, implementation complexity, and search-path latency due to lock acquisition and branch misprediction.
The system must therefore prioritize search latency and correctness over write parallelism.
Decision
Chassis adopts a Single-Writer / Multi-Reader (SWMR) concurrency model.
Enforcement Mechanisms
-
Exclusive Write Access (Inter-process): When the index is opened in write mode, the underlying file is protected by an exclusive OS-level lock (
flock/LockFile). This prevents multiple processes from mutating the same index concurrently. -
Exclusive Write Access (In-process): All mutating APIs (
insert,link_node_bidirectional,ensure_capacity) require&mut self, ensuring via Rust’s borrow checker that only one writer exists within a process at any time. -
Lock-Free Readers: Readers operate on immutable memory-mapped data structures and never acquire node-level locks. Search APIs accept
&selfand may execute concurrently across threads without synchronization. -
Crash-Safe Write Ordering: Writers follow a strict persistence order (node record → backward links → header update), ensuring readers only observe valid, prefix-consistent graph states (as defined in ADR-0002).
Consequences
Positive
Lock-Free Search Path
Search operations never acquire mutexes or perform existence checks. If a neighbor ID is present in an adjacency list, it is guaranteed to resolve to valid data. This enables consistently low-latency queries (P99).
Not after a crash. See the ADR-0005 amendment. “Multi-reader” means threads in one process: the file lock keeps every other process out, readers included.
Strong Corruption Guarantees
By serializing all mutations through a single writer, we eliminate entire classes of concurrency bugs, including race conditions, torn writes, and deadlocks.
Implementation Simplicity
The design avoids complex lock hierarchies, reference counting, or hazard pointer schemes. The OS kernel handles inter-process exclusion, while Rust enforces in-process safety.
Negative
Limited Write Throughput
All insertions are serialized through a single writer thread. Parallel graph construction is not supported. Mitigation: Batch insertions to amortize I/O and pruning costs.
Reader Constraints During Remap
When the file grows (ensure_capacity), the writer must remap the file, invalidating existing memory mappings.
Invariant: Readers must not retain mmap-backed references across writer-induced remap boundaries. Search operations must be scoped to a stable mapping.
Compliance
- Initialization Lock:
Storage::openattempts to acquire an exclusive file lock in write mode and returns an error if the lock cannot be obtained. - Borrowing Discipline: Search APIs require
&self; mutation APIs require&mut self, enforcing SWMR at compile time. - Safe Traversal: Neighbor iteration assumes all IDs are valid and performs no runtime existence checks.
ADR-0004: Diversity Heuristics & Lazy Distance Caching
Date: 2026-01-24
Status: Accepted, cache withdrawn 2026-10-04 (see Amendment)
Context
In HNSW graphs, the quality of the small-world navigation property depends heavily on how neighbors are selected during construction.
- Naïve Selection (Heuristic 1): Simply selecting the closest neighbors creates highly clustered graphs. This forces search algorithms to crawl through dense local regions, significantly increasing hop count and degrading navigability.
- Diverse Selection (Heuristic 2): The HNSW paper proposes selecting a neighbor only if it is closer to the base node than to any already-selected neighbor. This enforces long-range connections and creates graph-level “expressways”.
The problem is computational cost. Heuristic 2 is expensive: for a candidate set of size N and target size M, it requires O(N·M) distance checks. With high-dimensional vectors (e.g., 1536 dimensions), redundant distance computation becomes the dominant cost during graph construction, accounting for the majority of CPU time.
This ADR assumes a symmetric metric distance function (e.g., Euclidean distance), which guarantees distance symmetry and non-negativity.
Decision
We adopt Heuristic 2 for all neighbor selection and augment it with a Lazy Symmetric Distance Cache to eliminate redundant computation.
1. Heuristic Logic
We implement Heuristic 2 with a starvation fallback:
- Pruning Rule: A candidate is retained only if its distance to the base node is strictly smaller than its distance to every already-selected neighbor.
- Fallback: If pruning yields fewer than a policy-defined minimum number of neighbors, the remaining slots are filled using closest-first selection, ignoring diversity, to preserve graph connectivity.
The minimum degree is treated as a construction policy parameter rather than a strict mathematical invariant.
2. Stack-Allocated Lazy Cache
To eliminate the O(N·M) recomputation bottleneck, we introduce a specialized distance cache:
- Stack Allocation: A fixed-size
[f32; 1089]array is allocated on the stack, representing a 33×33 symmetric distance matrix. This avoids heap allocation in the construction hot path and is safe given the shallow call depth and typical multi-megabyte thread stacks. - Symmetry: Because the distance function is symmetric, computed values are stored in both
[i][j]and[j][i], halving the number of required computations. - Lazy Evaluation: Distances are computed only when requested by the heuristic. If a candidate pair is never compared, its distance is never computed.
Consequences
Positive
Significant Construction Speedup
Benchmarks show that the lazy cache reduces pruning time per node from roughly 200 microseconds to approximately 50 microseconds for typical parameters. Most diversity checks resolve via cache hits rather than repeated vector arithmetic.
High Graph Quality
The ability to apply Heuristic 2 without prohibitive cost results in graphs with stronger navigability properties, improving downstream search performance compared to naïve neighbor selection.
Zero-Allocation Hot Path
The pruning logic performs no heap allocation. This reduces allocator pressure, memory fragmentation, and latency variance during large batch insertions.
Amended 2026-10-03: only the distance cache is on the stack. The candidate and selection lists are
Vecs, so pruning does allocate.
Negative
Compile-Time Limits on M
The stack-allocated cache requires a compile-time maximum size. We currently enforce MAX_M = 32, supporting candidate sets up to 33 nodes. Increasing this limit would require a larger stack allocation or a slower heap-backed alternative.
Increased Complexity
The lazy cache introduces substantially more code and complexity compared to a simple distance loop. This is an explicit tradeoff in favor of predictable performance and graph quality.
Compliance
- Fixed Size Enforcement: The distance cache enforces a compile-time capacity limit.
- Lazy Access: The pruning logic mediates all distance access through a cache-aware helper, ensuring each pairwise distance is computed at most once.
- Symmetry Guarantee: All cache writes update both symmetric entries to maintain consistency.
Amendment (2026-10-04)
The cache never helped. Heuristic 2 takes each candidate once and compares it with each neighbor already kept, so no pair is ever looked up twice: an instrumented build counted 0 hits in 157 million lookups (20,000 × 128 and 3,000 × 1,536 dimensions). The pruning speedup quoted under Consequences can’t have come from it.
Its 33-entry capacity also cost graph quality. New nodes chose neighbors from only the 33 nearest
of their ef_construction candidates. With every candidate considered, as hnswlib does, search was
7–18% faster at equal recall on a 200k SIFT prefix, and SIFT-1M’s recall at ef_search 512 rose
from 0.998 to 0.999.
The cache and both 33-candidate caps (MAX_M in link.rs, MAX_CANDIDATES_FOR_HEURISTIC in
lib.rs) were removed on 2026-10-04, and the heuristic now computes each distance directly. Build
time on 20,000 × 128 random vectors didn’t change (7.7–8.2 s before, 8.1–8.2 s after), and recall@10
at ef_search 200 rose from 0.880–0.885 to 0.902–0.908.
ADR-0005: Crash-Consistent Linking Protocol
Date: 2026-01-24
Status: Accepted, amended 2026-10-03 (see Amendment)
Context
Chassis persists graph mutations directly into a memory-mapped file. A process crash or power loss during a write operation can leave persistent state in a partially updated form.
Common corruption scenarios in graph databases include:
- Dangling Pointers: A node refers to a neighbor ID that has not yet been allocated or written.
- Torn Logical Updates: A partial mutation leaves a node record in a structurally inconsistent state.
- Topology Partitioning: A crash leaves the graph disconnected due to lost or partially applied edges.
Traditional systems address these issues using a Write-Ahead Log (WAL). However, WALs introduce additional I/O (double writes), recovery complexity (log replay, checkpoints), and operational overhead. For Chassis, which prioritizes low-latency access and simplicity, a WAL is undesirable.
We require a lighter-weight mechanism that guarantees structural integrity at all times without a recovery phase.
This ADR assumes the guarantees provided by modern operating systems and filesystems for memory-mapped writes (page-level coherence and ordering). It addresses logical and structural consistency, not arbitrary bit-level corruption due to faulty hardware.
Decision
We implement a Strict Ordering Protocol for all graph mutations.
This protocol ensures that the persistent file is always in a structurally valid state, regardless of when a crash occurs.
The Atomic Write Sequence
All graph updates must follow this exact order:
-
Persist Node Record (Forward Links) The new node
Ais written to disk first, including all of its outgoing edges (A → neighbors). The globalnode_countis not incremented at this stage. -
Update Neighbors (Backward Links) Each neighbor referenced by
Ais updated individually to include a backlink toA. These updates may complete partially if a crash occurs. -
Update Header The global
node_count,entry_point, and related metadata are updated in the file header. Only after this step isAconsidered visible to the system.
This protocol relies on sequential node IDs (ADR-0002) and a single-writer model (ADR-0003).
Ghost Node Acceptance
We explicitly accept one benign inconsistency class: ghost nodes.
If a crash occurs after Step 1 but before Step 3, node A exists physically on disk but is not reachable:
- Its ID is greater than the header’s
node_count - It is unreachable from the graph entry point
- Readers ignore it entirely
Because file offsets are derived from the header’s node_count, future insertions will overwrite this region by construction. No garbage collection or recovery pass is required.
Consequences
Positive
No Write-Ahead Log
We eliminate journaling entirely. Persistence is handled with a single file and a single write path, halving write amplification and significantly simplifying the storage engine.
Structural Corruption Immunity
The ordering protocol guarantees that we never persist references to uninitialized or invalid data:
- Crash before Step 1: No observable change.
- Crash during Step 2: One-way edges may exist. These are legal in HNSW and do not break search.
- Crash before Step 3: The node is a ghost and safely ignored.
At all times, the on-disk graph remains structurally valid.
Zero-Recovery Startup
Opening the database requires no log replay, scanning, or consistency verification. Startup time is constant, regardless of index size.
Negative
Serialized Mutation Path
The protocol enforces strictly ordered writes, preventing parallel mutation of the graph. Write throughput is therefore bounded by a single thread. This is an intentional tradeoff in favor of correctness and simplicity.
Temporary Space Loss on Crash
A crash after Step 1 may leave unused space corresponding to a ghost node. In the current append-only, monotonic-ID design, this space is deterministically reclaimed on the next insertion. Long-term reclamation or compaction is deferred as future work.
Compliance
- Code Structure:
link_node_bidirectionalis explicitly structured to follow the Step 1 → Step 2 → Step 3 sequence. - Invariant Enforcement: Any reordering of these steps is treated as a correctness bug.
- Header Authority: The storage layer treats the header’s
node_countas the sole source of truth, ignoring any data beyond it during initialization and traversal.
Amendment (2026-10-03)
The Context assumed memory-mapped writes reach disk in program order. They don’t: the OS writes
dirty pages back in any order, so the step ordering only holds against a process crash. Only
flush() (msync + fsync) orders anything on disk.
Two gaps in the protocol, both fixed on 2026-10-03:
- Backlinks outlive their node. Step 2 writes backlinks into existing records, but
node_countis only persisted byflush(). After a crash, open rolls the node back while the backlinks survive, pointing at an ID the next insert reuses. Pruning such a list read the missing vector and failed every later insert.add_backward_link_with_pruningnow drops IDs at or pastnode_count, and search already skipped them. Once the next insert reuses the ID, the surviving backlinks are ordinary edges to the new node. - Moving the graph zone was not crash-safe. It copied the zone in place over an overlapping range, so a crash mid-copy destroyed the only valid graph. A copy now never overwrites the one the header points at (an overlapping move goes past both ranges first), and is fsynced before the header switches to it.
What holds now: after a process kill, reopening keeps every vector up to the last flush() and the
index takes new inserts, tested by chassis-core/tests/crash_tests.rs. Graph edges are not rolled
back: edges pruned after the last flush to make room for backlinks stay lost, which can lower recall.
The same is expected after power loss, because flush() is the fsync barrier.
Since 2026-10-08 the edges are rolled back too after a process crash: ADR-0012 saves a committed node’s lists before they change and writes them back on the next open. ADR-0011 measured what the loss had cost.
Since 2026-10-05, chassis-core/src/power_loss.rs simulates power loss. At every fsync and between
operations, each 512-byte sector of a crash image keeps its durable or its current contents, and
every image must reopen to exactly the last completed flush or the one in progress, then keep
accepting writes. Against format 2 it passed 248 images and caught each of five protocol mutants: a
missing fsync before the graph header, a missing fsync before a graph move switches, no dirty flag
before delete marks, in-place overlapping graph moves, and recovery disabled. Format 3 replaced
that code; the simulator’s results for it, which also tear sectors and crash inside recovery, are in
the ADR-0008 implementation status. It does not
model a sector persisted at an intermediate version, and real hardware is not tested.
ADR-0006: SIMD Acceleration
Date: 2026-01-24 Status: Accepted
Context
As Chassis moves from a “graph builder” to a “search engine,” raw arithmetic throughput becomes the dominant bottleneck. Profiling reveals that searching high-dimensional vectors (e.g., 1536-dimensional OpenAI embeddings) is limited by the CPU’s ability to process floating-point operations sequentially.
- The Bottleneck: Scalar execution processes one dimension at a time. Modern CPUs have vector units (AVX2, NEON) capable of processing multiple floats per cycle, but compilers often fail to autovectorize complex distance loops optimally.
- Dependency Chains: Standard accumulation (
sum += diff * diff) creates a dependency chain where each addition must wait for the previous one to complete. This exposes instruction latency (≈4 cycles) rather than throughput (≈0.5 cycles), leaving most execution units idle. - Target Performance: To achieve sub-millisecond search latencies at scale, we must saturate the CPU’s floating-point execution pipelines.
Decision
We will implement hand-written SIMD kernels for distance metrics using a 4-way unrolled accumulation strategy.
1. Hardware Targeting
We explicitly target the two dominant server and edge architectures:
- x86_64: AVX2 with FMA (Fused Multiply-Add).
- aarch64: NEON (standard on all modern ARM cores, including Apple Silicon and AWS Graviton).
2. The “4-Way Unroll” Pattern
We do not rely on compiler autovectorization. Instead, we explicitly break dependency chains:
- Maintain four independent vector accumulators (
sum0,sum1,sum2,sum3) inside the hot loop. - This allows the CPU to issue multiple independent FMA instructions concurrently, hiding instruction latency behind pipeline throughput.
- Result: Effective throughput scales from one dependent FMA per latency window to four independent FMAs, saturating available execution ports.
3. Safety & Dispatch
- Runtime Detection (x86): Use
std::is_x86_feature_detected!("avx2")to select the optimized kernel safely at runtime. - Compile-Time Detection (ARM): NEON is guaranteed on
aarch64and enabled via#[cfg(target_arch = "aarch64")]. - Scalar Fallback: A pure Rust implementation is retained for unsupported hardware and correctness validation.
All unsafe SIMD kernels operate under the following invariants:
- Input slices have equal length.
- Bounds are validated before entering unsafe code.
- Unaligned loads are used deliberately and verified via benchmarks.
Consequences
Positive
Order-of-Magnitude Speedup
Benchmarks confirm a ~10× improvement in raw distance calculation for 1536-dimensional vectors (≈750ns → ≈70ns). Throughput reaches ~23 Giga-elements/second (where one “element” corresponds to one float dimension processed), approaching the sustained hardware throughput ceiling on modern CPUs.
Search Latency Reduction
End-to-end search latency for 1536d vectors drops from ~330µs to ~35µs, enabling high-throughput workloads (>20k QPS per core on a single socket).
Amended 2026-10-03: these figures come from
search_bench, which searches a hand-wired 500-node graph, and an older machine. A graph built byVectorIndexwith 20,000 1536-d vectors searches in about 1.1 ms atef_search50 on an Apple M5, where SIMD reaches about 10 Gelem/s. See Performance.
Stable P99 Latency
By replacing compiler-dependent loops with explicit intrinsics, performance becomes deterministic and robust across compiler versions and optimization flags.
Negative
Unsafe Code Proliferation
SIMD intrinsics require unsafe blocks, increasing review burden and necessitating careful testing to avoid undefined behavior.
Maintenance Overhead
The distance implementation now has three parallel variants (Scalar, AVX2, NEON). Any new distance metric must be implemented and validated across all targets. To mitigate this, all SIMD dispatch is centralized in distance.rs.
Compliance
- Implementation:
chassis-core/src/distance.rscontains all SIMD kernels and the 4-way unrolled accumulation logic. - Dispatch: The public
euclidean_distanceentry point selects the appropriate kernel before entering unsafe code. - Verification: Benchmarks include explicit coverage for unaligned access and cross-implementation correctness.
ADR-0007: Caller Ids and Deletes
Date: 2026-10-04 Status: Accepted
Context
Chassis identified vectors only by insertion order and could not remove them. Applications built on it need to store their own ids (a document id, a row id) and to delete or replace vectors as their data changes.
Two constraints shaped the design:
- Files written before this change must keep working, with no migration.
- A crash must leave the index exactly as of the last completed
flush(). Replacing a vector is a delete plus an add, and a crash must never apply one without the other.
Decision
Ids
Every node record already starts with an 8-byte node_id, which old files set to the node’s slot
(its position in the graph zone). That field now holds the caller’s id, so existing files read as
“id equals slot” without any rewrite. Records are addressed by slot, which is kept in memory
(NodeRecord::slot) and never stored.
add(vector)assigns one past the largest id ever stored, so it never reuses an id.add_with_id(id, vector)fails if a live vector already hasid.u64::MAXis reserved.- While every id equals its slot (the graph header’s custom-ids flag is clear), lookups need no table. Once an id differs, the first lookup in a process scans the node headers once to build an id-to-slot table in memory.
Deletes
A delete is recorded in memory and only written by flush(). Search hides it immediately: deleted
nodes are still traversed, so the graph stays connected, but never returned.
The node header’s unused padding holds deleted_epoch (0 means live). The graph header holds the
epoch of the last flush that committed deletes, a dirty flag and the deleted count. A flush with
pending deletes:
- sets the dirty flag in the graph header and fsyncs, along with every record and vector written since the last flush;
- writes
deleted_epoch = epoch + 1into each deleted node’s header and fsyncs; - writes the graph header (new node count, new epoch, deleted count, dirty flag cleared) and fsyncs.
Step 3 commits the flush’s adds and deletes together. If a crash interrupts steps 1–3, the dirty
flag is still set at the next open, which clears every mark newer than the header’s epoch. Adds are
rolled back by node_count as before (ADR-0005).
Format version
Files that use either feature can’t be read correctly by older releases: 0.6.x would misread ids as slots and ignore deletes, and its open path silently resets a graph it can’t read. So every flush stamps the main file header with format version 2, which 0.6.x refuses with an error. Version 1 files open unchanged and become version 2 at their first flush.
Consequences
Positive
- No migration: version 1 files keep their ids and gain both features.
- Each flush is all-or-nothing for adds and deletes, including a delete and re-add of the same id.
chassis-core/tests/crash_tests.rschecks this by killing a writer mid-batch.
Negative
- An index that uses custom ids scans every node header once per process, on its first lookup.
- A flush with deletes costs three fsyncs instead of two.
- Deleted vectors keep their space, and search still walks through them. An index with many deletes gets larger and slower until it is rebuilt; compaction is future work.
- Releases before 0.7 can’t open a file once a newer release has flushed it.
ADR-0008: File Format v3 and Multi-Process Readers
Date: 2026-10-04 Status: Proposed. Accept only after the experiments in “Before Accepting” pass. Implemented on 2026-10-05, readers in other processes included; see “Implementation Status”.
Summary
Chassis aims to be to vector search what SQLite is to relational data: one embedded file that any process can open, that survives crashes, and that stays readable for decades. Two things block that today: one process at a time, and a file format that must change again before it can be promised stable. This ADR proposes one last breaking change, file format v3, designed so that:
- Committed data never moves or changes. The file grows only by appending segments, so graph
relocation and the pointer invalidation that comes with remapping disappear. Operations that
replace the graph wholesale (
rebuild_graph(), a newmax_connections) or reclaim space (vacuum, migration) write a new file and swap it in (decision 8). Apart from single-word edge updates (decision 5), nothing rewrites committed bytes a reader may be traversing; a writer that reopens the file after an unflushed exit reuses slots and regions past the committed counts, which readers only route through. - Data and index are separate. Vectors, ids, deletes and (later) metadata are the data and are never lost. The HNSW graph is derived from them and can always be rebuilt into a new file.
- Graph records shrink about 13×, from 2,192 to about 164 bytes per vector at the default
M = 16(8× atM = 4, 15× atM = 32). Files shrink less, since vectors don’t change: 5× on SIFT-1M and 1.6× on dbpedia, with bytes written within 3% of hnswlib’s file. Smaller records don’t by themselves make in-memory search faster: on identical graphs, record size and id width changed QPS by less than ±5%. - Many processes can read while one writes, without locks on the read path, by relying on HNSW tolerating slightly stale neighbor lists.
- The format carries SQLite-style read and write versions, so later features never force a breaking change, and old releases refuse files they can’t read instead of misreading them.
Multi-process readers can ship after v3. What v3 must get right is the format properties that readers need, so that adding them later needs no format change.
Context
Measured on 2026-10-04 (see the Performance page and the
bench/ann suite):
- Every node record reserves 2,192 bytes: room for 16 layers of u64 neighbor ids. 94% of nodes only use layer 0, which is 272 bytes. On SIFT-1M that made a 3.4 GB file against hnswlib’s 660 MB, and 4.2 GB peak memory against 0.93 GB.
- The vector zone and the graph zone share one file. When vectors outgrow their space, the whole graph is copied elsewhere in the file (ADR-0005 amendment). That copy is crash-safe now, but it means data moves, and anything holding an address into the old place, such as another process’s mapping, breaks.
- Growing the file remaps it. That invalidates every pointer into the mapping, which is why searches and writes can’t overlap without a lock, even within one process.
- An exclusive file lock keeps every other process out, readers included.
- Ids live in node records spread through the graph, so building the id table reads the whole graph zone.
SQLite’s lesson is that the file format is the product: applications store data for years and expect any future release to read it. Chassis can only make that promise once the format stops changing, so the remaining breaking changes should all land together.
Decision
1. Layout: one file of append-only segments
0 [header A]
64 KiB [header B]
128 KiB [live page: routing count, see decision 5]
192 KiB [segment 0][segment 1][segment 2] ... [heap chunks and table pages, interleaved]
The two header copies sit 64 KiB apart so that they never share a page (pages are 16 KiB on Apple
silicon and up to 64 KiB on Linux arm64). Every segment, heap chunk and table page also starts at a
multiple of 64 KiB, the Windows mapping granularity and a multiple of every supported page size; the
writer pads to the next boundary before appending one. Unpadded, segment k would start at 192 KiB + 4096·(40 + dims)·(2^k − 1), which mmap rejects (EINVAL) on Apple silicon whenever dims is not a
multiple of 4, and which is never 64 KiB-aligned for common dimensions.
A segment holds a fixed number of slots and contains, as three parallel arrays:
| Array | Per slot | Contents |
|---|---|---|
| Slot headers | 24 bytes | id (u64), metadata reference (u64, 0 = none), deleted epoch (u64, 0 = live) |
| Vectors | dims × 4 bytes | the vector, never rewritten after its flush commits |
| Level-0 records | 8 + M0 × 4 bytes | layer count, upper-layer reference, M0 neighbor slots as u32 |
Segment k < K holds 1,024 · 2^k slots. Every later segment holds C = 1,024 · 2^K slots, the
largest such power of two whose segment fits in 256 MiB: 262,144 slots (176 MB) at 128 dimensions,
32,768 (207 MB) at 1,536. The header stores K, so later releases never recompute it. With B = C − 1,024, slot s < B is in segment k = ⌊log2(s + 1,024)⌋ − 10 at offset s + 1,024 − 2^(k+10),
and slot s ≥ B is in segment K + ((s − B) >> log2 C) at offset (s − B) & (C − 1). Its address
is that segment’s file offset, plus the array’s base, plus the offset times the array’s stride.
Upper-layer neighbor lists (the 6% of nodes with more than one layer) go in an append-only upper heap, and metadata will go in a metadata heap. Heap chunks grow by the same scheme, and an entry never straddles two chunks. The segment and chunk tables don’t fit in a header (2^32 slots take 16,392 segments at 128 dimensions and 131,077 at 1,536), so they live in append-only table chunks, each written before the header that first counts it. The header holds only the table chunks’ offsets.
The file only ever grows by appending a segment or a heap chunk. Nothing is copied or moved during normal operation.
2. Data versus index
Slot headers and vectors are the data. A slot’s id, vector and metadata reference are written once, before the flush that commits them, and never change. Its deleted epoch is 0 until a flush deletes it; if that flush never commits, recovery resets it to 0.
The graph (level-0 records, upper heap, entry point) is derived from the data. Readers traverse it
while the writer works, so it is never regenerated in place. An in-place rebuild under live readers,
tested on 2026-10-04, dropped their recall@10 from 0.987 to between 0.13 and 0.59, and with a
different max_connections most queries failed outright. rebuild_graph() instead writes a new
file: it copies the committed slots, builds the graph from the vectors with the requested
max_connections, and swaps the new file in (decision 8). Copying vectors takes about 1 s per 512
MB, against about 750 s to build SIFT-1M’s graph. This is also how max_connections changes (it
changes the level-0 record size) and how the space of deleted vectors is reclaimed. Every flush
commits graph and slots together, so the graph always covers every committed slot.
3. Compact graph records
Neighbor ids become u32, with u32::MAX meaning “empty”, which caps an index at about 4.29 billion
vectors. The level-0 record is 8 + 8M bytes, 136 with the default M = 16. Upper layers cost 4M
bytes each, and a node has 1/(M − 1) of them on average: 4.3 bytes per node at M = 16 (4.27 when
sampled from the code’s own layer distribution). With the 24-byte slot header, overhead per vector
is 32 + 8M + 4M/(M − 1) bytes: 164 at M = 16, against today’s 16 + 136M (2,192). Sizes on
2026-10-04 (v3 columns simulated, the rest measured):
| Dataset | Vectors | v3 overhead | v3 bytes written | v3 file size | hnswlib | Today |
|---|---|---|---|---|---|---|
| SIFT-1M | 512 MB | 164 MB | 676 MB | 712 MB | 660.5 MB | 3,379 MB |
| GloVe, 1.18M × 100 | 473 MB | 194 MB | 668 MB | 742 MB | 649.2 MB | 3,289 MB |
| dbpedia, 99k × 1536 | 608 MB | 16 MB | 625 MB | 820 MB | 623.0 MB | 982 MB |
File size also counts the unused end of the last segment (36, 74 and 196 MB here). hnswlib saves an exact dump with no room to grow, so compare it with bytes written. v3 is 15.7 bytes per vector larger: its 24-byte slot header and 8-byte level-0 head, against hnswlib’s 8-byte label and 4-byte list headers.
Ids sit in a 24-byte-per-slot array at the start of each segment, so building the id table for 1M vectors reads 24 MB in 11 sequential runs instead of the whole graph.
M must be between 2 and 32,767. M = 1 puts every node on all 16 layers, and M0 = 2M must fit
in a u16.
The 33-candidate cap on neighbor selection, which mattered more than any layout effect measured (7–18% faster search at equal recall once lifted), was removed on its own on 2026-10-04 (see the ADR-0004 amendment), so v3’s benchmarks start from the uncapped code.
4. Double-buffered header
Each header copy carries a 64-bit sequence number and a 64-bit non-linear checksum (xxh3-64) over the whole copy. CRC32 is not enough: it is affine, so a reader that catches a header mid-write can assemble a mix of old and new words that passes it. LMDB alternates between two meta pages in the same way, without a checksum. A new file starts with copy A at sequence 1 and copy B at 0 with equal contents. Two valid copies with the same sequence number are corruption, and open fails.
A flush without deletes:
- writes all slot data, vectors, graph records and any new segment or heap chunk;
- fsyncs (
sync_all, which isF_FULLFSYNCon Apple platforms); - writes the copy that does not hold the newest valid header, in one copy operation, with sequence number newest + 1;
- fsyncs, and only then reports success.
The format assumes, as SQLite’s “powersafe overwrite” does, that a torn sector write never changes bytes outside the sectors being written.
Each header holds:
- magic, read version, write version and dimensions;
- metric,
M,M0and maximum layers; - committed slot count, entry point, max layer, delete epoch (u64), pending delete epoch (0 when none, see decision 6) and deleted count;
- references to the segment, upper-heap and metadata-heap chunk tables. The tables themselves live in append-only table pages: a 64 KiB header copy only has room for about 8,000 eight-byte entries, about 2 TiB of 256 MB segments, or 349M vectors at 1536 dimensions, well short of the u32 limit;
- the custom-id flag;
- reserved space.
5. Readers in other processes
A reader process opens the file read-only, with no lock. Per query it:
- copies each header copy into local memory, verifies the checksum on the copy, takes the valid one
with the higher sequence number, and issues an acquire fence. Every snapshot field comes from
that one local copy: reading fields from the mapping after choosing a copy mixed fields from two
commits 557 times in a measured run. A reader also re-checks the read version on every snapshot,
never runs recovery, and ignores
pending_epoch. That gives its snapshot: the committed slot countNand delete epochE. A copy that fails its checksum while the writer is running is re-read, a bounded number of times, rather than skipped. A reader never adopts a snapshot older than one it already used: if the newest valid copy is older, it keeps its previous snapshot, which stays valid because committed data never changes; - maps any segment or heap chunk it hasn’t mapped yet, and remaps an uncommitted one whose table entry changed: a writer that restarts after an unflushed exit reuses that space (committed mappings never change, because nothing committed moves);
- searches, treating a slot as present only if it is below
Nand its deleted epoch is 0 or aboveE.
The writer guarantees only these things:
- Committed data never changes. Vectors, ids and metadata references are immutable once committed, so metadata changes by delete and re-add, never by rewriting a reference. The writer reuses a slot only if it is beyond every committed count (ghost slots after a crash). Readers never return those, except that after a power loss a reader may already have returned slots from the lost commit. They may route through them while a restarted writer rewrites them, vectors included, which costs routing quality, never results.
- Graph updates are single aligned u32 stores made as relaxed atomics, and readers load them as
relaxed atomics too. Through a plain slice the compiler may re-read an id after its
< Ncheck, and a plain store is a data race in Rust’s memory model; on arm64 relaxed atomics compile to the sameldrandstr. A reader can see a neighbor list half old and half new, but every id it reads is a whole id. After commit, the writer touches only neighbor slots: never a committed record’s layer count or upper-layer reference, even with the same value, and never with a whole-record copy. When pruning replaces neighbors, surviving entries keep their slots and only dropped entries’ slots are overwritten, emptied slots last, so a reader can’t miss an edge that is in both the old and the new list. - Data is written before the header that commits it, with release ordering; readers load the header with acquire ordering. Both halves are needed: measured across processes on Apple silicon, dropping either let readers see a count ahead of its data.
- The routing count is published after every insert. Readers don’t see older neighbor lists.
They see the writer’s newer ones, already pruned toward nodes the reader can’t see yet. With
readers limited to slots below
N, measured recall on SIFT (100k growing to 200k, M=16) fell by 0.03 to 0.16 points at an unflushed backlog of 1% ofN, about 1 point at 5–10%, and 16 to 21 points during a bulk load flushed once. A reader that could traverse every written node kept its recall (0.975 at ef 64). So after each insert’s node and backlinks are written, the writer stores the routing countR ≥ Nin the live page with release ordering. Readers traverse slots belowRbut return only slots belowNthat are live atE.Ris not durable: recovery resets it to the committed count before reusing any ghost slot. A reader that loadedRbefore the reset, or is in the middle of a search, can still route through ghost slots the new writer is rewriting, and through uncommitted regions that now hold other data, so readers treat records at or pastNas hints and skip any they can’t decode. The same holds for a reader that opens the file after a power loss before any writer does: the live page it finds may describe records that never reached the disk. Auto-flushing every few hundred adds would also bound the loss, but it would make a caller’s batch durable in pieces, which breaks ADR-0007’s all-or-nothing flush. - The file never shrinks. No v3 code path sets a smaller length: not recovery, not
rebuild_graph(), not compaction. Space is reclaimed only by copy and rename (decision 8). Measured on macOS: after a 4 MiB to 1 MiB truncate, an attached reader died with SIGBUS, and after the file was extended again its pages read back as zero. The user docs warn that truncating or overwriting the file in place (cpover it,truncate) crashes attached readers. - Space is allocated before it is written. Before writing into a new range, the writer allocates
its blocks (
fallocate,F_PREALLOCATE, orSetEndOfFileon Windows) in steps of at most 16 MiB ahead of its write position, not a whole segment at once, so a full disk fails the flush with an error while the unused end of the last segment stays sparse. Writing through a mapping into a sparse range kills the writer with SIGBUS on Linux; on macOS, measured on a full disk image,msyncreturned success and onlyF_FULLFSYNCreported the error, and 2,688 of 4,096 pages were lost.
Within one process, v3 removes the remap, but that is only one of four obstacles to dropping the read side of the bindings’ lock:
add,deleteandflushtake&selfand run one at a time on an internal writer mutex. Rust rejects a&mutwriter beside&readers, and aliasing them through the FFI pointer is undefined behavior (Miri flagged today’s shape without the lock in 10 of 10 runs).Storagenever forms a&mut [u8]or&[u8]over bytes another thread may write.- Graph words, heap references and deleted epochs are read and written only as relaxed atomics through raw pointers (plain aligned stores raced under Miri in 4 of 10 runs).
- The state searches read is published atomically: slot count, entry point with max layer, deleted count, pending deletes, the custom-id flag and the id table.
In-process searches keep read-your-writes: they use this published state, not the header snapshot, so an add or delete is visible to them before the flush. Until all four are done, the bindings keep their lock.
Only one process may write. On Unix the writer holds flock(LOCK_EX) on the file, as every release
since 0.1 does, so older releases stay excluded. On Windows the whole-file LockFileEx lock taken
today makes every ReadFile by another process fail (though not reads through a mapped view), so
the v3 writer locks a single byte at offset 2^62 instead. That byte still overlaps the whole-range
lock older releases request, so they still exclude each other. Readers hold no lock.
Readers see a commit as soon as its header is written, before its fsync finishes. After power loss that commit may be gone, so a reader can have returned results the file no longer contains.
6. Crash safety
The commit point is the header write (decision 4), and adds, deletes and graph growth commit together, as in ADR-0007. ADR-0007’s dirty flag has no safe place in a double-buffered header: written in place, a torn write can lose both copies’ worth of state; placed in the commit header, it is committed before the marks it guards. A flush with deletes therefore commits through two headers:
- It writes its data, plus an intent header into the copy that does not hold the newest header:
sequence newest + 1, the previous commit’s state unchanged, and
pending_epoch = E + 1. Then it fsyncs. - It writes
deleted_epoch = E + 1into each deleted slot. Then it fsyncs. - It writes the commit header into the other copy, the one that held the previous commit, never
over the intent: sequence newest + 2, the new slot count, epoch
E + 1,pending_epoch = 0. Then it fsyncs.
Every add writes its slot’s full 24-byte header with deleted epoch 0, so a crashed flush can’t leave
a stale epoch in a slot that a later add reuses. On open with write access, if the newest valid
header has pending_epoch ≠ 0, recovery resets every deleted epoch above the header’s epoch in
slots below the committed count, fsyncs, writes a header with pending_epoch = 0 into the copy it
did not read, and fsyncs. Readers ignore pending_epoch. A power-loss model checker of this
protocol (1,194 crash continuations, including crashes inside recovery) found no violation; every
simpler placement of the flag failed it. The protocol assumes power loss never tears an aligned
8-byte write: recovery clears only marks above the committed epoch, so a mark torn at a byte
boundary (257 landing as 1) would survive it.
Recovery does not restore edges that an interrupted flush pruned from committed records, so some
committed vectors can be unreachable until rebuild_graph() (see the ADR-0005 amendment).
7. Versioning
As in SQLite’s file header, there are two numbers:
- Read version. The oldest reader that can read this file correctly. A release refuses a file whose read version is newer than it knows.
- Write version. The oldest writer that may modify it. A release opens a newer-write-version
file only read-only, with
IndexReader, ignoring header fields that follow the tables.
v3 sets both to 3. A later feature that older readers can safely ignore, such as metadata they don’t query, bumps only the write version, so older releases can still read those files.
Both header copies hold CHASSIS\0 at bytes 0–7, the read version at bytes 8–11 and the write
version at 12–15, as little-endian u32, at these offsets forever. Every release from 0.1 to v2 reads
bytes 8–11 as its version and refuses anything above what it knows before writing: tested on
2026-10-04 against the original code and the v2 branch, through Storage::open,
VectorIndex::open, the C API and Python, 64 of 64 opens of v3-shaped files were refused and every
file stayed byte-identical. A release checks the read version before the checksum, so a newer
header layout is reported as too new, not as corrupt.
Releases up to 0.6.3 initialize any file shorter than 4,096 bytes. v3 creates a new file in place while holding its lock, which keeps those releases out until the first header is durable. A crash before then leaves an empty file, which any release may initialize since it holds nothing, or a header-sized file whose header copies are zero or torn and whose live page is zero, which v3 treats as new: both copies are invalid at once only during creation. (This replaces a temporary name and a rename, which added a path a concurrent creator could race without protecting more.)
Starting with 1.0, every release reads every file format since v3, forever. Releases only refuse files, and only by these version checks; they never misread one.
8. Migration from v1 and v2
Migration is an explicit migrate(), also run on first open with write access. It converts the
graph (u64 to u32 neighbor ids, upper layers into the heap) instead of rebuilding it, and takes
O(n), once. It:
- takes the exclusive lock on the original and reads only its committed state: slots below the
graph header’s node count, and a delete only where
0 < deleted_epoch ≤the header’s epoch. Copying a crashed file’s raw epochs would turn its uncommitted deletes into committed ones; - creates
<name>.migrating, replacing any left by an earlier crash, and takes its exclusive lock; - writes and converts, then fsyncs it;
- renames it over the original;
- fsyncs the directory (
F_FULLFSYNCon Apple platforms,MOVEFILE_WRITE_THROUGHon Windows). Without this, a crash can bring the v2 file back after the first v3 flush was acknowledged; - keeps the already-locked new file open as the index.
It returns, and the first v3 flush can be acknowledged, only after step 5. File locks follow the
inode, not the path, so every open re-checks after taking its lock that its descriptor’s inode is
still the one at the path, and reopens if not. v1 and v2 files have no lock-free readers. Any later
operation that replaces a v3 file by rename (rebuild_graph(), a max_connections change, vacuum)
must first commit a “superseded” header flag. A reader that sees it finishes its current query on
its snapshot, then reopens the path until the inode differs from the superseded file’s. A reader
also reopens when fstat on its descriptor shows st_nlink == 0, which catches a replacement that
didn’t go through the superseded flag.
On Unix the migrator keeps the original’s descriptor and lock until it closes the index, which
narrows the window for releases before v3, since they don’t re-check the inode. Windows refuses to
rename over a file that has any open handle, its own included. So on Windows the migrator unmaps and
closes the original before step 4, and its lock goes with it. It then calls
MoveFileExW(MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH) directly, not std::fs::rename,
which on access denied silently retries with POSIX semantics and replaces the file under other
processes’ handles. If another process opened the original in that window, the rename fails, the
original stays in use, and migration retries on the next open.
Options Considered
| Option | Why not |
|---|---|
| Keep today’s layout, add multi-process readers with a lock | Graph moves and remaps would make every write block readers in every process; graph records stay 13× larger than needed, and files 5× hnswlib’s on SIFT-1M |
| Graph in a second file | Ends graph moves, but still remaps on growth, still large records, and two files to copy |
| SQLite-style write-ahead log | Needs a page table lookup on every record read, which breaks zero-copy search |
| LMDB-style copy-on-write pages | Every node lookup goes through a tree; a large redesign for guarantees HNSW doesn’t need |
| A directory of segment files | Solves Windows growth (see below) but gives up the single file |
| Store each vector next to its graph record (hnswlib’s layout) | Measured no faster than separate arrays in our search loop (0.89–1.02×), and mixes data with the derived index and makes rebuilding the graph a rewrite of every vector |
Implementation Status
On 2026-10-05. Built:
- Layout (decision 1): header copies at 0 and 64 KiB, the live page, then 64 KiB-aligned
segments, upper-heap chunks and table pages, as in File Format.
A heap reference is
chunk << 32 | offset, so chunks need no shared offset formula. No metadata heap is created yet; every slot’s metadata reference is 0. - Compact records (decision 3): u32 neighbor ids;
Mfrom 2 to 32,767. - Double-buffered header (decision 4): xxh3-64 over the header’s own bytes, which are 136 bytes plus 8 per table page, plus any fields a later write version appends, not the whole 64 KiB copy.
- Crash safety (decision 6): the intent header, recovery, and a full slot header on every add. One addition: after a failed fsync, every later commit fails until the index is reopened, since the OS may already have dropped the dirty pages a retry would claim to commit.
- Versioning (decision 7).
- Migration (decision 8) on first open, with the inode re-check on Unix. The Windows path
(
MoveFileExW) passes the migration tests onwindows-latestin CI. - Readers in other processes (decision 5):
IndexReaderin Rust,chassis_open_readerin C andread_only=Truein Python open the file read-only without a lock. Every search takes a snapshot as described above, copying header words atomically and re-reading a copy that fails its checksum while it keeps changing. The writer stores edges, delete marks, headers and the live page as relaxed atomic words with release fences before what publishes them, rewrites neighbor lists so surviving entries keep their slots, and publishes the routing count after each insert. One addition: the routing count can reach slots in a segment no header counts yet, so the live page also mirrors the writer’s segment, heap chunk and table page counts and table page offsets. Readers treat a record at or pastNthat they can’t decode as having no neighbors. A snapshot re-verifies the headers only when a copy’s checksum or sequence word changed since both copies last read valid, and reads the file length only to map a new uncommitted region: about 60 ns when nothing changed, against about 1.2 µs (anfstat, two checksums, eight allocations).
The writer takes the single-byte lock at 2^62 on Windows (decision 5) and flock elsewhere.
rebuild_graph() and the “superseded” flag are built as compact()
(ADR-0011). Not built yet: the st_nlink reopen check (every replacement of
a v3 file goes through the flag), lock-free reads within one process (the bindings keep their
lock), allocating space before writing to it, an explicit migrate(), and changing
max_connections.
Results of “Before Accepting”, measured on the same Apple M5 as the Performance page:
-
Windows: passes on NTFS; exFAT and one migration step untested. A probe on GitHub’s
windows-latest(branchprobe/windows-mmap, 2026-10-06), each check run against another process:- (a) extending a file another process has mapped works, both with
SetEndOfFileand withWriteFilepast the end, and the other process can map the new range at a 64 KiB offset and read it; - (b) shrinking it fails with
ERROR_USER_MAPPED_FILE(1224), as expected; v3 never shrinks; - (c) under a whole-range lock, another process’s
ReadFilefails (error 33); under a one-byte lock at 2^62 it works, and a whole-range lock request still conflicts with that byte, so releases up to 0.6.3 stay excluded; - (d)
MoveFileExW(REPLACE_EXISTING | WRITE_THROUGH)replaces a file nothing holds, and fails with access denied (5), leaving the original, while another process holds it open or mapped.std::fs::renamereplaced it in every case, under open handles and mapped views alike, which is why migration doesn’t use it.
Renaming the migrated file while this process still holds it open and mapped, as migration does, works on NTFS: the migration tests pass on
windows-latestin CI, as do the 7 reader tests that run there (one needs Unix), one with the writer in another process. Not tested: exFAT. - (a) extending a file another process has mapped works, both with
-
Search speed: passes on paired measurements. Today’s SIFT-1M and dbpedia graphs, converted, return byte-identical results (ids and distances) for every query at every ef from 10 to 512.
search.rsanddistance.rswere unchanged when this was measured, so each layout ran the same loop. The machine was not quiet (load average 5 to 10 from other applications), and separate processes varied 2 to 5×. So one process opened all three files, each searched by its own library build, and alternated passes: 7 processes × 5 repetitions per ef, after a warm-up pass. Median v3/v2 QPS ratio per ef:ef 10 16 32 64 128 256 512 SIFT-1M 1.056 1.015 1.018 1.049 1.031 1.070 1.089 dbpedia 1.087 1.095 1.097 1.052 1.099 1.109 1.117
SIFT at ef 512 counts only the 26 of 35 passes with no major page fault: v2’s 3.4 GB file took 14,050 faults there and v3’s 1. On the criterion as written, medians of each layout’s own QPS, SIFT at ef 32 came out at 0.950 (8,630 against 9,082), within that cell’s noise: its paired ratio is 1.018, with an interquartile range of 0.946 to 1.131. A quiet machine should confirm it. The flat control (one segment, no address arithmetic) puts segment addressing’s cost at 2–5% on SIFT (v3/flat 0.950 to 0.981) and none measurable on dbpedia (0.985 to 1.077). Files: 713 MB for SIFT-1M (3,379 MB in v2) and 821 MB for dbpedia (982 MB), as estimated above; allocated about 688 and 630 MB, estimated from a fill without linking, against hnswlib’s 661 and 623 MB. Migrating SIFT-1M took 9.4 s.
The reader work later changed the hot path: neighbor ids are loaded as relaxed atomics, record heads
with acquire ordering, and search.rs filters a reader’s snapshot. Re-measured against the code
measured above, interleaving the layouts query by query and timing thread CPU time, on SIFT 300k and
dbpedia: the shipped code’s paired ratio is 1.003 to 1.015 at every ef, with every 95% lower bound
at or above 0.997, and a relaxed head load is no faster. Against format 2 on the same SIFT 200k
graph, it is 1.06 to 1.085 (at least 1.051 at the 95% level). Reader searches, which take a snapshot
each time, run at 1.002 to 1.024 of the measured code.
3. Readers tolerating a writer: passes for adds, deletes and flushes; incomplete, because
rebuild_graph() is not built. One writer and two reader processes on SIFT: 100,000 vectors,
then 20 flushes of 1,000 adds and 50 deletes each, then 50,000 adds (42% of N) in one unflushed
bulk load. The readers searched continuously, 200 queries at each ef in {16, 32, 64, 128}, and
after every flush, with the writer idle, searched the same queries on the same snapshot; each
query is paired with that idle run, and recall@10 is against the exact top 10 of the snapshot’s
live vectors. 587,200 searches, zero errors, zero returned ids that were not live in their
snapshot, and no reader moving to an older snapshot. Mean paired loss in recall points (negative:
better than idle) with 95% intervals from a bootstrap over queries, and pairs losing 0.5 or more:
| ef | 16 | 32 | 64 | 128 |
|---|---|---|---|---|
| Flushed batches | −0.04 [−0.10, +0.01], 0% | −0.02 [−0.05, 0.00], 0% | −0.01 [−0.02, 0.00], 0% | −0.01 [−0.01, 0.00], 0% |
| Unflushed bulk load | −1.50 [−2.47, −0.56], 0.05% | −0.64 [−1.20, −0.09], 0% | +0.01 [−0.30, +0.34], 0% | −0.09 [−0.24, +0.02], 0% |
Two more runs gave −1.63 and −1.47 at ef 16 during the bulk load, and the batch row within 0.01. Readers looked for the idle window only between passes of 800 searches, so 7% of the batch pairs (17% at ef 128) ran with the writer already idle, where the loss is exactly zero; leaving them out changes no cell by more than 0.01.
The bulk load’s gain is not from concurrency. Its idle run came before the bulk load, and the concurrent searches routed through up to 50,000 unflushed nodes they can’t return; the filtered search keeps expanding such nodes until it holds ef returnable results, so these searches computed 12–17% more distances. With the writer paused, without flushing, after every 10,000 bulk adds, idle searches on the same snapshot gained 0.85 to 2.35 points at ef 16 (454 to 568 distances per query); against those paused runs, concurrent searches lost −0.50 to +0.44 points, every interval below +1.0. So writes in progress cost no measurable recall, but a criterion measured against the pre-bulk idle run can’t see up to about 2 points of such loss: this experiment should judge the bulk load against paused runs.
With routing disabled in the readers, the bulk load lost 1.15 [0.37, 1.98], 1.10 [0.57, 1.68], 0.64
and 0.11 points, and 1.49, 1.18, 0.63 and 0.11 in a second run: failing at ef 16 and 32 both times,
but only by 0.1 to 0.5 points, with intervals reaching below 1. This bulk load is too small to
reproduce the 16 to 21 points decision 5 measured with a backlog as large as N; the control should
use a larger one.
The run had no writer restarts. A separate in-process stress test with the unit-test geometry (4 to
6 readers, a writer that adds, deletes and drops itself unflushed in up to 80% of batches, about
12.6 million searches, 1,970 restarts, files up to 135 segments and 34 table pages) found no wrong
result. It did find two interleavings, both deterministic, in which a search spanning a writer
restart failed on a record the new writer was reusing; readers now treat records at or past N as
hints (decision 5), with a regression test. Not covered: rebuild_graph(), a writer killed and
restarted with readers in other processes, a flush with deletes and no adds, a table page boundary
in another process, more than two reader processes, Linux and Windows.
Test coverage: the reader tests catch disabled routing, disabled snapshot filtering, search without
a new snapshot and chassis_len without one. Monotonic snapshots, header re-reads, remapping,
region publishing, the routing reset on open, slot-preserving rewrites, ignoring an uncommitted
delete’s marks and publishing routing only after backlinks each have a targeted unit test. Memory
ordering (release and acquire on the routing count, the live page counts and record heads) is
covered by review only: neither the tests nor a litmus test on this machine can tell it from relaxed
ordering.
4. Power loss: chassis-core/src/power_loss.rs passes about 200 crash points: 50 in the
workload (two between operations and three fsyncs per flush, ten flushes) plus about 150 inside
the recovery a crash image triggers, about 510 crash images in all. Node levels come from an
unseeded RNG, so the recovery count varies: runs on 2026-10-05 and 06 gave 198 to 214 crash
points and 496 to 528 images. Unchanged sectors stay as they are; each changed one keeps its
durable or its current contents, or is torn into a mix of their 8-byte words. Unit tests use
16-slot segments, 1 KiB heap chunks and 4-entry table pages, so the workload crosses all three,
and every flush deletes a vector added in that same flush. Each image must reopen to the last
flush or the one in progress, then take one more add-and-delete flush and reopen to exactly that
state. It catches each of eight protocol mutants, re-run against the reader-era storage code with
3 runs each, every one failing an invariant at run time: no fsync before the commit header, no
intent header, no fsync between the intent and the marks, header copies that don’t alternate,
recovery disabled, recovery that keeps the marks, an add that doesn’t clear the slot header, and
a checksum that isn’t checked. The last two needed torn sectors: a header fits in one sector, so
whole-sector choices never tear it.
(b): before any writer reopens it, a reader opens every crash image as found. It must show an
accepted state, never fail a search, and return only live ids of that state: in two runs it missed
the top hit for 0 and 144 of about 50,000 searches, from routing through slots the power loss
emptied. Before readers treated records past N as hints, about 7% of such opens failed a search
(Invalid graph record), because the crashed writer’s live page reached the disk and records it
routes to did not. Creating a file is covered too: 100 crashes of its one fsync, which before the
fix left an unopenable file whenever both header copies tore. Not covered: orderings only readers
see within one sync interval (record head after its lists, routing after nodes, slot-preserving
rewrites), whose mutants pass the simulator; the unit tests above cover them.
5. Id table build time: met with the page cache warm, barely, and only with a faster build. The
first add_with_id at 10M slots builds the table; the add itself is under 0.5 ms of it. Reading
the 240 MB of slot headers takes 84 to 224 ms of CPU, depending on whether the pages are mapped,
cached or on disk; filling the hash table is about 93% of the build, from its cache misses rather
than hashing. Collecting the pairs first and filling a table keyed by a seeded multiply hash cut
the build to 0.67× in paired runs (0.57 to 0.75×); either change alone did nothing. With that, at
load average 6 to 15: 351 to 885 ms with the file in the page cache (median 468 ms, under 1
second in 5 of 5), and 419 to 1,316 ms right after copying the file, which on macOS leaves it out
of the page cache (median 913 ms, 3 of 5). Earlier runs of the original build, reported as warm,
were in fact cold (742 to 1,039 ms). Under heavy load (load average 34 to 59), the original build
took 1.1 to 1.8 s of CPU.
Before Accepting
Each of these is an experiment with a clear pass criterion:
- Windows. Windows refuses to shrink a file below a mapped view (
ERROR_USER_MAPPED_FILE). Extending is expected to work: Microsoft’s FastFAT source only checks on shrink, and SQLite’s WAL index grows withSetEndOfFileunder other processes’ views. Test: (a) extend withSetEndOfFileand withWriteFilepast the end while another process has views, then map the new range with a new section at a 64 KiB offset; (b) shrinking fails; (c) aReadFilereader works under the single-byte writer lock; (d) migration step 4 on NTFS and exFAT, both with the migrator’s own handles closed and with a third process holding the original. - Search speed of the new layout. Convert today’s SIFT-1M and dbpedia graphs into a v3 prototype (u32 records, segment addressing, upper heap) instead of rebuilding them, and confirm both files return identical result lists for every query, so recall is equal by construction. Time single-thread QPS for ef 10–512 with: one open per file for the whole run and an untimed warm-up pass after every open (a fresh mapping pays page faults on its first pass: measured at about half the warm rate on SIFT-1M at ef 16); no major faults during timing (wire the mappings or check rusage); at least 7 rounds alternating the files; the same search loop for both layouts (today’s search code ran 0.86–0.96× a lean rewrite on the same bytes, so a new prototype would look faster for code reasons); and a flat u32 array control, so segment addressing’s cost is reported apart from record size. Pass: v3 median QPS at least 0.97× today’s at every ef. Report cold-open (first pass after open) QPS separately. Don’t compare separately built graphs near the top of the recall curve, where build-to-build variance (0.996 against 0.998 at ef 512 on SIFT-1M) dominates. File size: report logical length and allocated blocks; pass if allocated bytes are within 10% of hnswlib’s file and the logical length within 10% plus one segment.
- Readers tolerating a writer. One writer and at least two reader processes, searching
continuously while the writer adds (both in small flushed batches and as one bulk load of
Nnew vectors with no flush), deletes, flushes and runsrebuild_graph(). Every query records its snapshot(N, E)and is compared with the same query on the same snapshot while the writer is idle, not with the final index: a measured run passed against the final index (0.9609 against 0.9614) while the paired loss was 1.12 points. Pass: zero reader errors (invalid record, an id at or pastNor deleted in the snapshot returned, panic, signal); at every ef in {16, 32, 64, 128}, mean paired recall@10 loss of at most 1 point and at most 1% of queries losing 0.5 or more. - Header commit under power loss. A power-loss simulator that drops, reorders and tears
unsynced writes, run against the real implementation of decisions 4, 6 and 8
(
chassis-core/src/power_loss.rs; results under “Implementation Status”). Crash points include inside recovery, a delete of a vector added in the same flush, and a flush that appends a segment. Pass: (a) every reopen shows the last acknowledged flush or the one in progress, never older; (b) a reader opening the crashed file without recovery sees that same state; (c) after every reopen, one more delete flush and a clean reopen show exactly that state plus the flush. A model checker (2026-10-04) showed that “every reopen shows exactly one committed state” alone accepts 6 of 8 broken protocols. - Id table build time. Building the id table at 10M slots stays under 1 second with the page cache warm.
Consequences
Positive
- Files 5× smaller on SIFT-1M and 1.6× on dbpedia (graph records 13× smaller), with bytes written within 3% of hnswlib’s.
- No data moves and no remaps, which removes a whole class of crash and concurrency bugs and the code that handles graph relocation.
- Many readers across processes, with lock-free reads.
- Opening reads two header copies and maps one region per segment and chunk on first use: 11 for SIFT-1M, about 2,350 for 100M vectors at 1536 dimensions. Nothing is loaded up front, except that each process looking up custom ids builds its own id table (a 287 MB table at 10M ids, measured as peak memory footprint; peak resident memory, 527 MB, also counts the 240 MB of slot headers mapped from the page cache).
- Changing index parameters becomes a rebuild into a new file instead of an error.
- A format that can be frozen, with a SQLite-style compatibility promise.
Negative
- One more breaking change, with a migration step for existing files.
- At most about 4.29 billion vectors per index (u32 neighbor ids).
- Segment addressing adds a lookup to every vector and record access: a prototype measured 1–9% lower QPS than a flat array on SIFT-1M.
- File size counts up to one segment (147–212 MB at 100–1,536 dimensions, never above 256 MiB) plus one chunk per heap that holds nothing yet. While segments are still doubling, file size is up to twice the bytes written (10k × 768: 50 MB against 32 MB). Disk use only grows in the writer’s 16 MiB allocation steps on APFS and ext4; NTFS allocates the whole extension, and tools that don’t keep holes copy it in full.
- Readers never get wrong results, but while the writer adds they lose a little recall; the routing count keeps that loss small, and experiment 3 bounds it.
rebuild_graph(), changingmax_connectionsand reclaiming deleted space each need free disk for a second copy of the index while they run.- Windows multi-process support depends on experiment 1.
- Releases up to 0.6.3 ignore the inode re-check, so one opening the path during a migration can lock and write to the replaced file, and that write is lost.
ADR-0009: Filtered Search
Date: 2026-10-07 Status: Proposed
Context
Applications rarely want the nearest vectors in the whole index. They want the nearest among one user’s documents, one language, the rows a permission check allows. Chassis stores vectors and caller ids only, and its docs tell applications to keep everything else in their own store, such as SQLite.
Filtering after the search doesn’t work. Asking for k / s results to keep k that match a filter
passing a fraction s of the index returns too few whenever the matches aren’t spread evenly, and
costs 1 / s times a normal search when they are.
Two things already in the code shape the design:
- Deletes are a filter. Layer-0 search walks through deleted nodes but leaves them out of the results (ADR-0007), and readers apply their snapshot the same way (ADR-0008). A caller’s filter can use the same path.
- The format reserves room for stored metadata. Every slot header has a metadata reference, and ADR-0008 plans a metadata heap, added later with only a write-version bump. Until this ADR’s review that promise didn’t hold: a newer write version stopped readers too, and so did a longer header. Both are fixed, while format 3 is still unreleased.
Decision
1. The caller supplies the filter
search_filtered(query, k, filter) takes a function from a caller id to bool, in Rust on both
VectorIndex and IndexReader. C (chassis_search_filtered) and Python (allowed=) take a list
of allowed ids instead, since a Python callback per visited node would cost more than the search;
the list becomes a hash set for the search. Chassis stores no metadata. An application keeps it
where it already does and passes the ids its query selects.
2. Walk the graph, and give up when a scan is cheaper
Layer-0 search treats rejected nodes like deleted ones: they are traversed, so the graph stays
connected, but never returned. Measured below, that keeps mean recall@10 at about 0.98 or better
at every selectivity. Its cost is the problem: to find ef matches when a fraction s of the index
matches, it visits about 1 / s times more nodes. At 1% it visits half of dbpedia, and at 0.1%
all of it.
So a filtered search first runs the filter on about 1,024 slots, one per equal stretch of the
index at a jittered position so a filter periodic in the id can’t line up with them, to estimate
how many vectors match, m, and gives the graph search a budget of the smaller of m / 2 and
32 × ef / s distance computations. If it finishes within the budget with k results, they
stand. Otherwise the search checks every slot and returns the exact nearest matches. A graph
search that finishes with fewer than k ran out of nodes, not budget, which a connected graph
doesn’t allow: it means a crash that lost adds left committed nodes too few links to reach each
other (ADR-0005 amendment).
m / 2. Checking every slot computesmdistances, but in order. A graph visit reads a vector and a neighbor list from random places and keeps two heaps: from the visit counts of the graph-only runs below, it costs 0.5 to 2 times a scanned distance for random filters on both datasets, and up to 6 times for filters far from the query on SIFT, whose heaps grow large. Half the matches is about the scan’s cost in the usual case.32 × ef / s. An unfiltered search visits about16 × efnodes atef = 64(10 to 18, inferred from random filters passing 3% to 30%), so a filter whose matches are spread at random needs about16 × ef / svisits. A filter whose matches lie far from the query needs many more, because the search crosses everything nearer first: on SIFT-1M, 310,000 visits for a filter passing 30% of the index. Twice the random need cuts those off early.
The budget counts distance computations, not time, so the same query on the same index always takes the same path.
3. Stored metadata stays out, and can come later
Keeping metadata in the caller’s store covers filters of any kind with no format change, so ADR-0008 can freeze without it. If Chassis later stores metadata, the slot headers’ metadata references and a metadata heap carry it, behind a write-version bump that older releases read through.
Measurements
On 2026-10-07, on a shared Apple Silicon workstation (4 performance and 6 efficiency cores, load
average 5 to 7), one search
thread, M = 16, ef_construction = 200, ef_search = 64, 200 queries per row, recall@10 against
the exact nearest matches. Four filters per fraction: random ids, looked up in a Vec<bool> (r) and
in a HashSet (s), as an allow-list is; the Voronoi cell of 1 / fraction random pivots that holds
the query (n, matches clustered near it); another cell (f, matches far from it). Matches are
nominal; a near cell can hold twice as many. “Exact” computes distances to the matches only;
cargo run --release --example filtered reproduces it and the shipped columns. “Graph only” is a
patched build without the budget, from an earlier run on the same machine, with other random ids
and pivots of the same shapes. r runs first at each fraction, on a cache the cell assignment just
evicted, so at 30% it reads slower than s, which filters the same ids. Times in ms.
dbpedia-openai3-1536 (99,000 OpenAI embeddings, 1,536 dimensions; unfiltered 1.2 ms):
| Fraction | Matches | Exact r / s / n / f | Graph only r / n / f | Shipped r / s / n / f |
|---|---|---|---|---|
| 30% | 30,000 | 15 / 16 / 17 / 15 | 4.0 / 2.9 / 5.4 | 7.7 / 3.4 / 2.6 / 10 |
| 10% | 10,000 | 6.6 / 7.7 / 7.0 / 6.7 | 8.0 / 3.9 / 18 | 11 / 12 / 5.1 / 10 |
| 3% | 3,000 | 2.4 / 4.3 / 3.0 / 2.6 | 20 / 6.5 / 38 | 3.5 / 5.5 / 4.4 / 4.1 |
| 1% | 1,000 | 0.5 / 1.5 / 1.1 / 1.0 | 37 / 8.5 / 58 | 0.9 / 1.9 / 1.8 / 1.6 |
| 0.3% | 300 | 0.3 / 1.2 / 0.5 / 0.5 | 87 / 15 / 99 | 0.6 / 1.5 / 0.9 / 0.8 |
| 0.1% | 100 | 0.2 / 1.7 / 0.3 / 0.3 | 106 / 39 / 102 | 0.5 / 2.0 / 0.6 / 0.5 |
SIFT-1M (1,000,000 vectors, 128 dimensions; unfiltered 0.36 ms; graph only not run below 3%):
| Fraction | Matches | Exact r / s / n / f | Graph only r / n / f | Shipped r / s / n / f |
|---|---|---|---|---|
| 30% | 300,000 | 42 / 51 / 31 / 32 | 1.1 / 0.6 / 263 | 3.3 / 0.7 / 0.4 / 25 |
| 10% | 100,000 | 17 / 41 / 17 / 15 | 3.0 / 0.5 / 212 | 1.3 / 1.5 / 0.4 / 17 |
| 3% | 30,000 | 7.9 / 17 / 11 / 7.3 | 3.4 / 0.5 / 132 | 9.2 / 15 / 0.7 / 11 |
| 1% | 10,000 | 4.7 / 16 / 5.2 / 4.0 | 7.8 / 18 / 2.1 / 6.9 | |
| 0.3% | 3,000 | 2.7 / 21 / 3.3 / 2.7 | 4.7 / 23 / 3.4 / 5.1 | |
| 0.1% | 1,000 | 2.6 / 11 / 2.6 / 2.7 | 5.0 / 12 / 4.7 / 4.9 |
- The shipped search takes at most 2.1 times the exact scan in every row of both tables, and at
ef_search = 256(not shown). Its recall is 0.98 or better at 64 and 0.998 or better at 256, and exact wherever it scans, up to ties between equal distances (SIFT has duplicate vectors). - The graph alone keeps its recall but not its speed. Random filters below 1% visit nearly the whole index: at 0.1% on dbpedia, 620 times a scan of every slot in the same run. Filters far from the query are slow at every fraction: on SIFT, 6 to 26 times the scan, and 310,000 visits at 30%.
- The cost of the budget. Where the graph alone is fast, the budget can give up on it too early: a random 3% filter on SIFT takes 9.2 ms against 3.4 for the graph alone in the earlier run. It spends about 15,000 visits before giving up, and scanning a million slots costs about 5 ms however few match. Counting that cost into the budget keeps such searches on the graph, but lets far filters, whose visits cost up to 6 times a scanned distance, run longer. Measuring both costs at run time would fit each machine, but makes the path, and so the results, vary between runs of the same search.
- Allow-lists pay for hash lookups. Scanning a million slots with a
HashSetfilter costs 10 to 23 ms. A scan driven by the list itself would cost only its length, but needs an id-to-slot map, which readers don’t keep.
Options Considered
- Filter after searching. Fails as described in the context.
- Filter inside HNSW only, as hnswlib does. Measured above: up to 620 times slower than scanning every slot for selective filters, and 6 times for a filter passing 30% whose matches lie far from the query.
- A fixed selectivity threshold for switching to a scan. Where the two paths cross depends on
the index size (the graph visits about
V / snodes, a scan computess × Ndistances, so they cross nearm = √(V × N)matches) and on the filter: matches clustered near the query cost the graph search a sixth to three quarters of what random ones do, and matches far from it up to twice as much on dbpedia and 40 to 240 times on SIFT. A budget adapts to all three. - Storing metadata with a filter language, as Qdrant and Weaviate do. Much larger, and a metadata store is what the introduction says Chassis isn’t. Decision 3 keeps the option.
- Graphs built for filters, like ACORN or Filtered-DiskANN. They need the filters at build time, or more edges per node, to make very selective filters fast. At the sizes Chassis targets, a scan already answers those exactly in milliseconds.
- A C callback instead of a list. A list serves C and Python alike; a callback can be added if a C caller needs a computed filter.
Consequences
Positive
- Filtered search in Rust, C and Python, exact whenever the filter is selective, at most about twice an exact scan of the matches in every measured case.
- No format change, so ADR-0008 can freeze without metadata, and format 3 can now gain metadata later without locking out older readers.
Negative
- The filter runs once per visited node, and once per vector when scanning: an expensive filter slows every filtered search. Allow-lists from C and Python are hashed for every search.
- A scan checks every slot, so a selective filter costs time in proportion to the index: about 5 ms per million vectors on the measured machine, before the matches.
- The budget’s two constants are fitted to two datasets on one machine, and favor bounding the worst case over keeping every search that the graph alone would finish fast.
ADR-0010: Parallel Batch Builds
Date: 2026-10-07 Status: Proposed
Context
Chassis links one vector at a time (ADR-0002), so a build uses one core: SIFT-1M took 805 s on the measured machine. hnswlib links on every core, and anyone comparing the two meets the difference in their first five minutes.
ADR-0002 chose sequential construction to keep one invariant: every edge points at a node whose data is already written. Two things since then make a parallel build fit that invariant:
- Lists are written for concurrent readers. Readers in other processes traverse neighbor
lists while the writer changes them (ADR-0008): every entry is an atomic word, and
store_listrewrites a list so a surviving entry keeps its place. Threads of the writer can traverse the same way. - A record can exist before it is linked. A slot’s vector, id and graph record are written before anything links to it, and readers skip ids past the routing count they were given.
Decision
1. add_batch and add_batch_with_ids
VectorIndex::add_batch(vectors) takes vectors back to back and returns their ids, assigned as
add assigns them; add_batch_with_ids(ids, vectors) takes the caller’s. C’s chassis_add_batch
now uses it, chassis_add_batch_with_ids is new, and Python gains add_batch(vectors, ids=None).
A batch is added whole or not at all: ids and dimensions are checked first, and a failure while
writing or linking rolls the slots back.
2. Write the batch, then link it on every core
The writer first appends every vector of the batch, draws its layer, and writes an empty graph record for it, one at a time. Then a thread per core takes the batch’s nodes in order and links each one: it searches the graph from the current entry point, selects neighbors with the usual heuristic, writes its own lists, and adds a backlink to each neighbor, pruning as before. Every edge points at a written vector and record throughout, so ADR-0002’s invariant holds.
3. One lock per list change, none for reading
Every change to a node’s lists, its own and each backlink with its pruning, holds that node’s lock, one of 4,096 striped by slot. A thread holds one lock at a time, so threads can’t deadlock. Searches read lists without locks, as readers in other processes do, and may see a list mid-change; they tolerate that the same way.
No search reaches a node of the batch before it has written its own lists. Following lists doesn’t ensure that: a batch lost to a crash leaves links to its slots in committed nodes (ADR-0005 amendment), and the next batch reuses those slots. A node found through such a link while its lists were empty was a dead end, and a node that found itself that way linked to nothing. So each node of the batch has a flag, set once its lists are written, and reading a list skips the nodes whose flag isn’t set.
A node whose layer is above the current top holds the entry-point lock while it links, as in hnswlib, so the new top connects to the old one. That is rare (the top layer grows with the log of the index size), and other threads wait for it.
4. Readers see the batch when it is linked
The routing count readers follow is published once the whole batch is linked, so readers in
other processes skip the batch’s ids until then, and see it in their results after the next
flush(), as for single adds. A crash during a batch leaves the file as the last flush left it,
with backlinks to rolled-back slots handled as after any crash (ADR-0005 amendment).
5. Thread timing shapes the graph
Which nodes a thread sees depends on what other threads have linked, so two builds of the same batch differ, as two sequential builds already do through their random layers.
Measurements
On 2026-10-07, on an Apple M5 with 4 performance and 6 efficiency cores, in Low Power Mode, with
other applications running. Builds ran one at a time, with M = 16 and ef_construction = 200;
“every core” is add_batch for Chassis and num_threads=-1 for hnswlib 0.8.0. Searches ran on one
thread over 1,000 queries. cargo run --release --example ann -- <data> <dataset> [batch] and
python bench/ann/reference_bench.py hnswlib <data> <dataset> [threads] reproduce them.
| Dataset | Engine | One thread | Every core | Speedup |
|---|---|---|---|---|
| SIFT-1M (128 dims) | Chassis | 545 s | 110 s | 5.0× |
| hnswlib | 682 s | 131 s | 5.2× | |
| dbpedia (99k × 1,536 dims) | Chassis | 417 s | 176 s | 2.4× |
| hnswlib | 596 s | 163 s | 3.7× |
Recall@10 / queries per second of each graph, built on one thread → built on every core:
| Dataset | Engine | ef = 64 | ef = 256 |
|---|---|---|---|
| SIFT-1M | Chassis | 0.970 / 4,338 → 0.968 / 4,111 | 0.998 / 1,681 → 0.998 / 1,484 |
| hnswlib | 0.960 / 2,934 → 0.959 / 2,878 | 0.997 / 1,041 → 0.997 / 883 | |
| dbpedia | Chassis | 0.978 / 938 → 0.978 / 841 | 0.998 / 303 → 0.998 / 271 |
| hnswlib | 0.970 / 484 → 0.970 / 481 | 0.996 / 163 → 0.996 / 161 |
- Parallel builds match on recall. Within 0.002 from
ef = 32up, for both engines; at 10 and 16, Chassis’s SIFT graph was up to 0.007 lower. - Search speed can’t be compared from these runs. One pass per
efvaried by up to 40% between graphs, in both directions and for both engines (Chassis’s parallel SIFT graph was 44% faster atef = 10and 35% slower at 32). Paired, repeated searches would be needed to see a difference. - Chassis builds at about hnswlib’s speed on every core: faster on SIFT, within run-to-run variation on dbpedia (an earlier pair of parallel builds took 159 s and 158 s).
- dbpedia scales less for both engines. Its vectors are 6 KB, twelve times SIFT’s, so memory bandwidth may limit it; this wasn’t measured. Low Power Mode and the efficiency cores cap every number here; a desktop with only performance cores should scale further.
- Threads don’t wait on each other. Sampling a dbpedia build, every worker spent about 80% of its time searching and 18% pruning backlinks, and under 1% waiting on a lock.
Consequences
Positive
- Builds use every core, through the same API in Rust, C and Python.
- No format change, and crash and reader guarantees are unchanged.
Negative
- A batch uses every core, with no way yet to limit it; an application serving searches from the same process competes with its own build.
- A batch’s graph depends on thread timing as well as on the random layer draws every build makes.
- A batch holds the write lock for its whole duration, so searches through a shared C or Python handle wait for it.
- Two parts aren’t pinned by a test. Without the backlink lock, two threads can each add their node to a neighbor’s list and one addition is lost, which lowers quality too little for a test to see, as in hnswlib. Nothing in a test can make linking fail, so the rollback of a failed batch runs only in review.
ADR-0011: Compaction
Date: 2026-10-08 Status: Proposed
Context
A deleted vector keeps its slot: its vector, id and graph record stay in the file, and searches keep walking through it (ADR-0007). An application that replaces documents grows its file without bound, and its searches slow down as they step over more deleted nodes: measured below, five times slower with 90% deleted, though recall doesn’t fall. Nothing repairs either.
A crash that loses adds damages the graph too. The lost vectors’ backlinks replaced older edges in the committed nodes’ lists, in place, and reopening rolls the vectors back but not the lists (ADR-0005 amendment). Measured below: when the lost adds outnumber the committed vectors, most of those can no longer be found, though every one is still in the file.
ADR-0008 settled how any wholesale change must happen: never in place, because readers in other
processes are traversing the file, but by writing a new file and renaming it over the old one
(decision 8), after committing a “superseded” flag so readers move over. It left the operation
itself, rebuild_graph(), unbuilt, and its third acceptance experiment waits on it.
Decision
1. One operation: compact()
VectorIndex::compact() rewrites the index without its deleted vectors and with a newly built
graph, and replaces the file. It is ADR-0008’s rebuild_graph() and its vacuum in one, since both
are the same copy; C gets chassis_compact and Python compact(). It flushes first, so every add
and delete so far is durable whether or not the rest succeeds.
2. Build a new file beside the old one
compact() creates <name>.compacting, replacing one a crash left, with the index’s dimensions,
metric and graph parameters. It copies each live slot’s id and stored vector, in slot order, and
links them with the parallel batch build (ADR-0010). Ids don’t change, so callers’ references and
a reader’s results stay valid; slots do.
Two things the old file showed only through its deleted slots are carried in the new header:
- The id high-water mark.
addnever reuses an id (ADR-0007), which it knew from the largest id in any slot, deleted or not. The header gains a field, after its tables, holding one past the largest id ever used;addstarts from it. - The delete epoch. A reader’s snapshot is named by its committed count and delete epoch. The new file’s epoch is one more than the old one’s, so no snapshot of the new file shares a name with an earlier one.
3. Swap it in
Once the new file is fsynced, compact():
- commits the superseded flag in the old file’s header;
- renames the new file over the old path and fsyncs the directory, as migration does;
- continues on the new file, whose writer lock it has held since creating it.
A failure before step 1 removes the new file and leaves the index as it was. If the rename fails, the flag is cleared again. A crash between 1 and 2 leaves the old file in place with the flag set: the next writer to open it clears it. After a power loss either file may be at the path; both are complete, and they hold the same vectors under the same ids.
4. Readers follow
A reader that finds the superseded flag in a new snapshot opens the path again. If the file there is a different one, it continues on that; if it is still the same file, because the rename hasn’t happened yet or never will, it keeps its snapshot and looks again on its next search. Its mapping of the old file stays valid throughout, since an open file outlives its name.
On Windows a file can’t be renamed over while any process has it open. compact() there closes
its own handles first, and fails if another process has the index open. It then reopens the old
file, which shows what was flushed: the reason it flushes first. If the old file can’t be reopened
either, the index refuses to flush anything more until it is opened again.
open resolves symlinks in the path, so the rename replaces the file and not a link to it.
Measurements
On 2026-10-08, SIFT-1M on the M5 used for ADR-0010 (in Low Power Mode, under heavy load from other
work, so queries per second are rough), M = 16, ef_construction = 200, 1,000 queries, the
median of three passes, recall@10 against the exact nearest live vectors.
cargo run --release --example churn -- <data> sift-128 reproduces it.
| State | Live vectors | File | ef = 64: recall / QPS | ef = 256: recall / QPS |
|---|---|---|---|---|
| Built | 1,000,000 | 713 MB | 0.968 / 5,140 | 0.998 / 2,035 |
| 50% deleted | 499,522 | 713 MB | 0.987 / 3,568 | 0.999 / 1,079 |
| 90% deleted | 99,511 | 713 MB | 0.999 / 1,038 | 1.000 / 355 |
| Compacted, in 11 s | 99,511 | 88 MB | 0.988 / 8,986 | 1.000 / 3,424 |
- Deletes cost speed and space, not recall. A search keeps going until it has
eflive results, so with more deleted nodes it visits more and finds a little more: recall rises while queries per second fall to a fifth. - Compaction gives both back. The file shrinks 8 times, to the size of an index built from the
live vectors, and search at
ef = 64is 8.7 times faster. At recall no lower than before (ef = 256after againstef = 64before), it is 3.3 times faster.
After a crash that lost adds
ADR-0012 has since fixed this for a process crash: a writer now writes the committed lists back when it opens the index. These numbers are from before it, and still describe an index opened without its undo file, or after a power loss that took it.
The same day and machine, 24-dimensional random vectors, M = 16, ef_construction = 100,
ef = 64: commit some vectors, add more without flushing, reopen as after a crash, add the lost
ones again, compact. Each cell is recall@10 over 200 queries, then how many of the committed
vectors a search for that vector’s ten nearest returns.
cargo run --release --example lost_adds -- <committed> <lost> reproduces it.
| Committed | Lost | Before | After the loss | Lost ones added again | Compacted |
|---|---|---|---|---|---|
| 20,000 | 2,000 | 0.989, 20,000 | 0.986, 20,000 | 0.987, 20,000 | 0.987, 20,000 |
| 20,000 | 20,000 | 0.986, 20,000 | 0.935, 19,999 | 0.978, 19,999 | 0.979, 20,000 |
| 500 | 5,000 | 1.000, 500 | 0.327, 188 | 0.996, 472 | 0.996, 500 |
| 1,000 | 20,000 | 1.000, 1,000 | 0.101 to 0.331, 178 to 429 | 0.976 to 0.977, 713 to 730 | 0.985 to 0.986, 1,000 |
| 100 | 20,000 | 1.000, 100 | 0.032, 3 | 0.982, 3 | 0.986, 100 |
- The damage grows with what was lost against what was committed. A tenth as many lost adds cost nothing measurable. Twenty times as many left a third of the committed vectors findable, or fewer. The fourth row is three runs: layers are drawn at random, and the outcome varies a great deal, down to searches that return fewer than ten results.
- Adding the lost vectors again doesn’t bring the committed ones back. Recall over the whole index recovers, because most of it is the new vectors. But a committed vector that lost every link pointing at it is never reached, so nothing links to it again: over a quarter of the 1,000, and 97 of the 100, stayed out of reach.
- Compaction restores all of them, and nothing else does. Until someone compacts, the index answers with what it can still reach.
Consequences
Positive
- Deleted vectors’ space comes back, and the graph is rebuilt from the vectors alone, which also restores the edges a crash took from committed vectors.
- The last unbuilt piece of ADR-0008 exists, so its third experiment can run.
Negative
- It needs free disk for a second copy of the live data while it runs, and takes as long as building the index, on every core.
- It holds the writer for its whole run: adds, deletes and, through a shared C or Python handle, searches wait. Readers in other processes keep searching.
- On Windows it can’t run while another process has the index open.
- Where ADR-0012’s undo file doesn’t reach (a power loss, an older release, a copy without the file), someone has to know to call it after lost adds: nothing detects that damage.
- ADR-0008 also described a reader reopening when its file has no name left (
st_nlink == 0), for replacements made without the flag. That check isn’t built: every replacement goes through the flag. - ADR-0008’s third experiment asks for paired recall measurements across processes while the writer compacts. What is tested here is narrower: in one process, two readers search through ten compactions with no failed search and no wrong result.
ADR-0012: Rolling Back Lost Adds
Date: 2026-10-08 Status: Proposed
Context
An add changes the neighbor lists of nodes that are already committed, in place, before it is committed itself: each neighbor gets a link back to the new node, and a full list drops an older link to make room. A crash rolls the new nodes back, since the header still counts only the committed ones, but not the lists (ADR-0005 amendment). The committed nodes are left with links to slots that are gone, in place of the links they had.
ADR-0011 measured what that costs. With 1,000 vectors committed and 20,000 lost, a search for a
committed vector found it for 178 to 429 of the 1,000. Adding the 20,000 again brought that to
about 720: a node that has lost every link pointing at it is never reached, so nothing links to
it again. With 100 committed, 3 were found, before and after. Only compact() restored them,
and nothing ran it.
The same leftover links were behind two bugs. A batch reusing the lost slots could strand its own nodes (ADR-0010, fixed with a flag per node), and a filtered search could return fewer ids than its filter accepts (ADR-0009, fixed with a fallback to the scan).
ADR-0005 rejected a write-ahead log for every write as too costly. What is missing is much narrower: the committed lists, as they were, for the nodes an uncommitted add has touched.
Decision
1. Save a committed node’s lists before they first change
Before any list of a committed node changes for the first time since the last commit, the node’s
lists on every layer are appended to an undo file, <name>.undo, next to the index. A bit per
committed slot records that it is saved, so a node is saved once per commit however often it
changes after that. Nodes added since the commit are not saved: a crash takes them whole.
An add saves all the neighbors it is about to link back from in one write, since a write per node costs many times more. Every place that changes a list also checks the bit itself, so no caller can change a committed list unsaved.
The file starts with a magic number. Each entry is the slot, its layer count, each layer’s neighbors that are themselves committed, and an xxh3-64 checksum seeded with a hash of the committed graph: its vector count, entry point, top layer, delete epoch, deleted count and id mark. Entries saved from any other commit fail the checksum.
The file is created with the first entry, so an index built in one go, or a batch’s own nodes linking to each other, never creates one.
2. A writer writes them back when it opens the index
Opening an index for writing reads the undo file, writes each saved node’s lists back, syncs the index, and removes the file. The adds made since the commit are gone, and now so are their links in committed nodes: the graph is the one the commit left.
If two threads of a batch save the same node, the first entry in the file is the one read before any change (a list changes only after an entry for it is written), so that one is used. Parsing stops at the first entry that is torn or fails its checksum. Writing back is idempotent, so a crash during it changes nothing: the next open does it again.
Readers don’t write, so a reader that opens the file after a crash sees the damaged graph until a writer has opened it, as before.
3. A commit starts over
A commit that changes the graph removes the undo file and clears the saved bits, after its header is durable. Before that commit forces the changed lists to disk, it fsyncs the undo file, so a power loss during a flush can’t keep the lists and lose what was saved for them.
A header written only for a flag, or for a flush’s intent to delete, commits the same graph, so the undo file stays valid across it.
4. The file follows the index
Compaction and migration build a new file and rename it over the path; the undo file’s path follows. Neither leaves one behind: compaction flushes first, and a new file has nothing committed until its first flush.
What holds
- After the process dies, or an index is dropped without a flush: opening it for writing leaves every committed node with the neighbors the last flush gave it, exactly.
- After a power loss: the same for every list whose saved entry reached disk. Between flushes the OS may write a changed list before the entry saved for it; that list then keeps what ADR-0005’s amendment described. It is never worse than before this ADR.
- An index copied or opened without its undo file, or by a release from before this ADR, behaves as before: the lost adds’ links stay, and compaction removes them.
Measurements
On 2026-10-08, on the M5 used for ADR-0010 and ADR-0011 (in Low Power Mode, under load from other work).
The graph after lost adds. ADR-0011’s table again, with the undo file: recall@10 over 200
queries, then how many of the committed vectors a search for them finds.
cargo run --release --example lost_adds -- <committed> <lost> reproduces it.
| Committed | Lost | Before | After the loss | Lost ones added again |
|---|---|---|---|---|
| 20,000 | 2,000 | 0.988, 20,000 | 0.988, 20,000 | 0.986, 20,000 |
| 20,000 | 20,000 | 0.987, 20,000 | 0.987, 20,000 | 0.979, 20,000 |
| 500 | 5,000 | 1.000, 500 | 1.000, 500 | 0.996, 500 |
| 1,000 | 20,000 | 1.000, 1,000 | 1.000, 1,000 | 0.985, 1,000 |
| 100 | 20,000 | 1.000, 100 | 1.000, 100 | 0.988, 100 |
What it costs. 128-dimensional random vectors, M = 16, ef_construction = 100: onto
200,000 committed vectors, a batch of 50,000, a flush, 5,000 single adds, a flush, then 200
times one add and a flush. Three runs with and three without, alternating. CPU time, since the
machine was too loaded for wall time to mean much.
cargo run --release --example undo_cost -- 200000 50000 5000 reproduces it.
| Without | With | |
|---|---|---|
| Batch of 50,000, CPU on all cores | 37.1, 37.8, 39.1 s | 37.9, 39.0, 39.0 s |
| Single add, CPU | 513, 535, 627 µs | 473, 523, 544 µs |
| One add and a flush | 12.3, 12.6, 12.6 ms | 18.3, 18.3, 18.4 ms |
| Undo file after the 5,000 adds | none | 8.5 MB |
- Adds cost the same, within what these runs can show.
- A flush after adds onto committed vectors costs one more fsync: 6 ms here, on a Mac, where an fsync flushes the drive’s cache. A flush of a new index, or one with no adds since the last, writes no undo file and pays nothing.
- The file is bounded by the committed graph, at about 150 bytes for a node whose lists are full, because a node is saved once per commit. Here it grew by 1.7 KB per add.
Consequences
Positive
- A crash, a kill, or a drop without a flush leaves the committed graph as the last flush left it. Searches after one find what they found before it.
- The random-history test (
model_tests.rs) now demands a connected graph after every reopen that loses adds, where before it had to excuse them until the next compaction. - The links that stranded batch nodes and cut filtered searches short no longer survive a process crash. Their fixes stay, for power losses and for files older releases wrote.
Negative
- A second file can exist beside the index: after a crash, and between a flush and the adds that follow it. An index moved or copied in that state has to take it along.
- One more fsync per flush that follows adds onto committed vectors.
- After a crash, the next open reads the whole undo file and syncs the index before returning.
- A power loss between flushes can still leave some lists as ADR-0005’s amendment described.
- A batch that fails while linking is not rolled back this way: the undo file holds the lists as committed, and writing those back would also drop links to vectors added before the batch and not yet flushed. Linking can’t fail short of a corrupt file, so this is left as it was.
Alternatives Considered
- Save only the links each add displaces. Smaller entries, but one for every link an add makes, with no bound until the next flush, and a write for each.
- Rebuild damaged nodes when a writer opens. No extra file, but finding them means reading every committed list on every open, and relinking a node needs a graph that can still reach its neighbors, which is what is missing in the worst case.
- Compact when a writer opens after a crash. Exact, but as slow as building the index, for damage that is usually small.
- Hold back the links to new nodes until the flush. Committed lists would never change early, but neither readers in other processes nor later adds would reach the new nodes through them, and the flush would still change them in place.
- A write-ahead log. ADR-0005 rejected it: every write twice, and replay on open.
ADR-0013: Prefetching into L2, a Leaner Search Loop, and a Smaller Fill
Date: 2026-10-08 Status: Proposed
Context
On GitHub’s x86 runners hnswlib searched faster than Chassis, by a factor that changed with the CPU: 1.4 times on an EPYC 9V74 (Zen 4), about even on a Xeon 8573C, while on an EPYC 9V45 (Zen 5) and on ARM Chassis led. Its one-thread builds were 1.35 to 1.6 times slower on every x86 machine. The speedups of 2026-10-07 (prefetching neighbors’ vectors, one visited filter per thread) had helped a great deal on ARM, some on the Xeon, and not at all on Zen 4.
To find out why, an experiment branch ran both engines in one job per machine, with hnswlib
compiled there with -march=native: the distance kernels alone, an index small enough to stay in
cache, and SIFT-1M, counting distance computations in both. Several x86 jobs and one ARM job ran
per push, on whatever CPUs GitHub handed out: EPYC 7763 (Zen 3), 9V74 (Zen 4) and 9V45 (Zen 5),
Xeon 8573C (Emerald Rapids) and 8370C (Ice Lake), and Neoverse-N2.
What it showed:
- Not the kernel. At 128 dimensions Chassis’s AVX2 kernel takes 6.2 ns on Zen 5 and 12.8 ns on Zen 3; hnswlib’s, with AVX-512 where the machine has it, takes 7.3 and 13.4. At 960 and 1,536 dimensions they are level. An AVX-512 kernel would gain nothing here.
- The prefetch. A node has up to 32 neighbors, and Chassis asked for the first eight cache lines of each vector into L1: up to 256 hints in a burst. AMD cores before Zen 5 track 24 misses to L1 at a time and drop hints past that; Zen 5 tracks 124. On Zen 4, on Neoverse-N2 and on the Xeons, asking for the same lines into L2 instead is better, on the first two by far.
- Work per neighbor. Where the index fits in cache, Chassis ran at 0.67 to 0.80 of hnswlib’s speed. For every neighbor the loop found the vector’s slot twice (once to prefetch, once for the distance), tested the CPU’s features, called a kernel that can’t be inlined, and read the worst result off a heap.
- A denser graph. With the same
M, Chassis computed 11 to 13% more distances per query than hnswlib and got more recall for them (0.968 against 0.960 atef = 64). A selection that kept fewer than half a list was filled to the whole list with the nearest of the rest, which on real data is nearly every selection. So every list was full, every backlink had to prune one, and builds did about twice the pruning. - Huge pages. The x86 runners give anonymous memory transparent huge pages, so hnswlib’s index sat on 2 MB pages and Chassis’s file mapping on 4 KB ones. Turning them off for hnswlib cost it 6 to 13%.
Decision
1. Vectors are asked for into L2, except on Zen 5
Before computing distances to a node’s unvisited neighbors, a search asks for the first eight cache lines of each one’s vector. It asks for them into L2. On AMD processors of CPUID family 0x1A and up, Zen 5 and its successors, it asks into L1. The choice is made once per process.
Eight lines into L2 was the best of what was tried on Zen 4, both Xeons and Neoverse-N2, and on Zen 3 as good as anything. Only Zen 5 did better with L1, by a tenth or more.
2. A candidate’s neighbor list is asked for when it is queued
A node’s level-0 list is in another array than its vector, so expanding a node began with a miss that nothing else could overlap. hnswlib asks for the list of the best candidate after each push. Chassis asks for the list of every candidate it queues.
3. Less work per neighbor
The loop locates each unvisited neighbor’s vector once and keeps the slice for the prefetch and the distance; it looks the kernel up once per search and calls it through a pointer; and it keeps the worst result’s distance in a local. Selecting a new node’s neighbors takes the distances its search has just computed, where it recomputed and re-sorted them.
None of this changes a result: on 1.9 million results over five indexes, with deletes, filters and a reader, ids and distances are the same bit for bit as before.
4. A short selection is filled to a quarter of the list, not all of it
Selection keeps a candidate only if it is closer to the node than to every neighbor already kept. Filling the list with the rest undid that on nearly every node. The HNSW paper and hnswlib have no fill at all. Chassis keeps a small one: if fewer than a quarter of the list was kept, the nearest of the rest make up the quarter. Vectors that lie along a line leave only two diverse neighbors each, a chain that one lost link cuts, and the kill tests, whose vectors do lie on a line, fail without any fill.
This changes the graphs that new builds produce, not the file format.
Measurements
All on 2026-10-08, on GitHub’s runners (4 vCPUs), SIFT-1M, M = 16, ef_construction = 200,
search on one thread as the median of five passes, each engine run two or three times per job.
A figure “against hnswlib” is Chassis’s queries per second over hnswlib’s in the same job, at the
same ef, and a range covers ef 32 to 256 and every machine of that kind. Runners of the same
model differ, so nothing here compares one job with another.
Where the hints go
The experiment branch’s own loop (decision 3 as a prototype, with decision 2), asking for eight lines of each vector, against hnswlib:
| CPU | Into L1 | Into L2 |
|---|---|---|
| EPYC 7763 (Zen 3) | 0.87 to 1.01 | 0.85 to 1.02 |
| EPYC 9V74 (Zen 4) | 0.79 to 0.87 | 1.34 to 1.60 |
| EPYC 9V45 (Zen 5) | 1.73 to 1.90 | 1.56 to 1.75 |
| Xeon 8573C | 1.01 to 1.21 | 1.07 to 1.37 |
| Xeon 8370C | 0.87 to 0.89 | 0.86 to 0.93 |
| Neoverse-N2 | 1.48 to 1.66 | 2.30 to 2.78 |
Before and after
The code before this ADR and after it, in the same job. “Same index” has both search the index the old code built, so only the search differs; “its own index” has the new code search the index it built, with the smaller fill. Zen 4 and the Xeon come from earlier runs of the same loop, the Xeon’s with hints into L1; the final policy was not run on either.
Search at a million vectors, against hnswlib:
| CPU (machines) | Before | After, same index | After, its own index |
|---|---|---|---|
| EPYC 7763, Zen 3 (4) | 0.73 to 0.79 | 0.88 to 0.93 | 0.94 to 0.99 |
| EPYC 9V74, Zen 4 (2) | 0.66 to 0.75 | 1.49 to 1.63 | 1.53 to 1.68 |
| EPYC 9V45, Zen 5 (1) | 1.40 to 1.43 | 1.61 to 1.68 | 1.66 to 1.74 |
| Xeon 8573C (1) | 0.89 to 0.92 | 0.97 to 1.00 | 1.03 to 1.04 |
| Neoverse-N2 (2) | 1.46 to 1.57 | 2.48 to 2.81 | 2.52 to 2.89 |
Search at 20,000 vectors, which stay in cache, each version on its own index:
| CPU | Before | After |
|---|---|---|
| EPYC 7763 | 0.67 to 0.79 | 0.78 to 0.86 |
| EPYC 9V74 | 0.65 to 0.84 | 0.84 to 0.94 |
| EPYC 9V45 | 0.70 to 0.82 | 0.79 to 0.85 |
| Xeon 8573C | 0.76 to 0.92 | 0.92 to 1.00 |
| Neoverse-N2 | 1.34 to 1.54 | 1.59 to 1.75 |
Builds, in seconds: 100,000 vectors on one thread, then the million on all four vCPUs.
| CPU | hnswlib | Before | After |
|---|---|---|---|
| EPYC 7763 | 18 to 22, 138 to 149 | 30 to 34, 187 to 198 | 18 to 19, 133 to 139 |
| EPYC 9V74 | 16 to 25, 130 to 158 | 26 to 38, 186 to 251 | 15 to 20, 96 to 123 |
| EPYC 9V45 | 13, 110 | 18 to 19, 93 | 12, 72 |
| Xeon 8573C | 20, 141 | 31, 181 | 19, 138 |
| Neoverse-N2 | 35 to 40, 187 to 192 | 32 to 36, 141 to 149 | 19 to 20, 92 |
Recall@10 at ef 32, 64, 128 and 256, over all the machines:
| 32 | 64 | 128 | 256 | |
|---|---|---|---|---|
| Before | 0.907 to 0.912 | 0.967 to 0.970 | 0.992 to 0.993 | 0.998 |
| After | 0.905 to 0.909 | 0.966 to 0.967 | 0.991 to 0.992 | 0.997 |
| hnswlib | 0.890 to 0.895 | 0.957 to 0.960 | 0.988 to 0.989 | 0.997 |
- Search at a million vectors is 1.2 times faster than before on Zen 3 and Zen 5, 2.2 times on Zen 4, 1.7 to 1.8 times on Neoverse-N2, on the same index. It leads hnswlib everywhere measured but Zen 3, where it is within 6% on its own index, and Ice Lake, which the final code was not run on.
- In cache Chassis still trails hnswlib on x86, by up to a fifth. What is left of the work per neighbor is the next thing to look at there (ADR-0014).
- One-thread builds are 1.6 to 1.9 times faster and level with hnswlib’s on x86, where they took half as long again. Nearly all of that is the smaller fill.
- The smaller fill costs about 0.003 of recall at
ef = 32, 0.002 at 64 and under 0.001 above, and on the graphs it builds a search is up to 7% faster.
What Was Tried and Left Out
- A rolling window: asking for the vector a few neighbors ahead while computing a distance. Better than L1 hints all at once on Zen 3 and Neoverse-N2, but worse on Zen 5 (1.0 to 1.3 times hnswlib’s speed against 1.7) and far behind L2 hints on Neoverse-N2 (1.7 against 2.5).
- Reads in place of hints, which can’t be dropped: they stall the loop instead. 0.5 to 0.7 of hnswlib’s speed on x86.
- Fewer lines per vector. In the experiment branch’s loop, three lines into L1 was the best setting on Zen 3, about level with hnswlib. The shipped loop reaches 0.76 to 0.94 with it, no better than with eight lines, in three further runs that changed one thing at a time. The cause was not found, so nothing is built on it.
- An AVX-512 kernel. The AVX2 kernel is already as fast as hnswlib’s AVX-512 one.
- Link-time optimization and one codegen unit: nothing on the search loop.
- hnswlib’s rule for a new node, which selects
Mneighbors on layer 0 where Chassis selects up toM0. Builds were 5% faster again, but recall per distance computed was worse than withM0and no fill.
Two things helped and are left for later. Compiling the whole crate for x86-64-v3 gave 3 to 9%, which a search loop compiled for AVX2 with the kernel inlined should also give. And on Linux 6.17 with ext4, asking for huge pages on the mapping gave 6 to 8% on Zen 3 and Zen 4 once the index had been read back from disk; without them hnswlib’s own index, which gets them on the x86 runners, was 4 to 13% slower.
Consequences
Positive
- Search is faster on every CPU measured, and no longer behind hnswlib on Zen 4 or Emerald Rapids at a million vectors.
- Builds on x86 are level with hnswlib’s on one thread and ahead of it on four.
- No change to results from the search loop, and none to the file format.
Negative
- The policy is one rule with one exception, fitted to six CPUs. A CPU that wants something else gets hints into L2, which was never much worse than the best measured except on Zen 5.
- New builds produce sparser graphs: recall at a given
efis lower by up to 0.003, and a caller that tunedefto a recall target may have to raise it slightly. - A vector’s lines past the first eight are left to the hardware prefetcher, as before. Nothing here was measured above 128 dimensions (ADR-0015 does).
- The measurements are one dataset on shared runners.
ADR-0014: Packed Heap Entries, Squared Distances, and a Search Loop That Inlines
Date: 2026-10-09 Status: Proposed
Context
ADR-0013 left one case behind: where the index fits in cache, Chassis searched slower than hnswlib on x86. Measured again on 2,000 and 20,000 SIFT vectors it ran at 0.79 to 0.87 of hnswlib’s speed on Zen 3, Zen 4 and Zen 5, and led only from 100,000 vectors up.
The experiment branch of ADR-0013 ruled out what it could with both engines in one job:
- Not more work. Chassis computed up to 3% more distances per query than hnswlib, expanded the same number of nodes, and looked at 5 to 12% more neighbors.
- Not huge pages. Without them hnswlib was as fast at 2,000 vectors and 2 to 6% slower at 20,000.
- Not the prefetch policy. One line or eight, into L1 or L2, or no hints at all: 0.78 to 0.92 of hnswlib’s speed on the three AMD processors.
- Not the kernel. It took 13 to 17% of Chassis’s search time.
So both engines were sampled with perf on the CPU clock, about a thousand samples a second, and
the samples were laid over the instructions. Shares of search time at 20,000 vectors and
ef = 256, with decision 1 already in place:
| EPYC 9V45 (Zen 5) | Xeon 6973P-C | |
|---|---|---|
| The two heaps | 28% | 21% |
| Marking neighbors visited | 13% | 6% |
| The distance kernel | 13% | 17% |
| Reading a node’s neighbor ids | 9% | 5% |
| Hints for vectors | 9% | 23% |
| Locating a vector | 8% | 7% |
| The square root and the comparison after it | 5% | 5% |
| Sorting the results | 4% | 4% |
| Hints for neighbor lists | 3% | 5% |
| Everything else | 8% | 7% |
What stood behind the rows:
- The heaps. An entry was a slot and a distance, 16 bytes, compared with
f32::total_cmp, which rewrites both floats’ bits before every comparison.BinaryHeap::popwas not inlined. A candidate nearer than the worst result was pushed and the worst then popped: one walk up the heap, one to the bottom and one back up. - Visited. The filter lists the words it sets a bit in, so that the next search clears only those. Whether a word is still empty was a branch, and on an index of thousands to hundreds of thousands of vectors it goes either way about as often.
- Locating a vector. The lookup formats an error message when a slot is out of bounds. That made it too large to inline, so every neighbor cost a call that returned through memory.
- The square root sat between each distance and the branch that depends on it.
- The hints. The loop issuing them worked out its bounds again for every neighbor and chose L1 or L2 per line. On the Xeon the hints themselves were slow to retire.
- The kernel was the smaller part in Chassis and the larger in hnswlib: 9 ns per distance
against 21 ns on the Xeon at 2,000 vectors and
ef = 32. hnswlib’s kernel waits on the vector’s cache misses; Chassis has asked for the vectors by then and pays for the hints instead.
A profile of the loop after decisions 1 to 6 showed one more thing, at the other end of the
scale. At a million vectors and ef = 32 the descent through the upper layers took a tenth to a
fifth of the search. It asked for nothing ahead, so every distance waited on its own cache
misses, and its kernel sat behind a closure that was not compiled for AVX2 and so was called, not
inlined.
Decision
1. The search loops are compiled once per kernel
Kernel is a trait with two implementations: Avx2, and Portable (NEON on aarch64, plain
arithmetic elsewhere). The layer-0 loop and the greedy descent are generic over it and inlined
into a wrapper compiled for AVX2 and FMA, which a search enters after one feature test. The
kernel inlines into the loop and the loop itself may use AVX2. ADR-0013 expected 3 to 9% of this
from what compiling the whole crate for x86-64-v3 gave. It gave about 1%.
2. Lookup errors are built out of line
The out-of-bounds and bad-record errors are built in functions marked cold. The lookups shrink to a few instructions and inline into the loop.
3. A heap entry is one integer
A node and its distance are packed into a u64: the distance’s bits above the slot. A distance
is never negative, so its bits order as its value does, and the integers order by distance, then
slot. An entry is half the size, a comparison is one instruction, and nothing is left to call.
A candidate nearer than the worst of ef results replaces it at the top of the heap and sinks
from there, one walk in place of three. The results are sorted as integers before they are
unpacked.
4. Distances stay squared inside a search
The kernels return the sum of squares, which orders vectors as the distance does. The root is
taken of the results a layer search returns, ef of them, not of every distance it computes.
5. The visited filter lists a word without a branch
It writes the word’s index at the end of its list every time and advances the end only if the word was empty.
6. A search works out its hints once
How many lines of a vector to ask for, and how many of them into L1, is settled before the loop. Asking is two counted loops with nothing to decide inside.
7. The upper layers ask for vectors too
The greedy descent asks for every unvisited neighbor’s vector before it computes a distance, as layer 0 does, and its kernel is inlined.
What changes in results
- Results at the same distance come back in slot order. They came back in whatever order the heap left them.
- A NaN distance is farther than every other, whatever its sign.
total_cmpput a negative NaN first. - A candidate nearer than the worst result is now taken even when the two distances’ roots round to the same float. On five indexes of 5,000 to 60,000 vectors, with deletes, filters and a reader, 96,000 result lists came out the same as before but for seven, which differ in the order of two results at the same distance. At a million vectors recall moved by under 0.001, upward in all but one case.
- Builds run the same search, so the graphs they produce can differ where candidates tie. The file format does not change.
Measurements
On 2026-10-08, on GitHub’s runners (4 vCPUs), SIFT, M = 16, ef_construction = 200, search on
one thread as the median of three passes, each engine run three times per job, every version
searching the same index. A figure “against hnswlib” is Chassis’s queries per second over
hnswlib’s in the same job at the same ef, with hnswlib compiled there with -march=native; a
range covers ef 32 to 256 and every machine of that kind. Nothing here compares one job with
another.
Before and after
The code of ADR-0013 and the code of this one, against hnswlib, and the second over the first.
2,000 vectors:
| CPU (machines) | Before | After | Speedup |
|---|---|---|---|
| EPYC 7763, Zen 3 (6) | 0.80 to 0.88 | 1.10 to 1.17 | 1.31 to 1.38 |
| EPYC 9V74, Zen 4 (3) | 0.82 to 0.89 | 1.12 to 1.20 | 1.34 to 1.37 |
| EPYC 9V45, Zen 5 (2) | 0.81 to 0.88 | 1.08 to 1.15 | 1.32 to 1.40 |
| Xeon 6973P-C (1) | 0.90 to 1.02 | 1.13 to 1.30 | 1.25 to 1.29 |
| Neoverse-N2 (2) | 1.23 to 1.46 | 1.45 to 1.74 | 1.16 to 1.21 |
20,000 vectors:
| CPU (machines) | Before | After | Speedup |
|---|---|---|---|
| EPYC 7763, Zen 3 (6) | 0.79 to 1.00 | 1.15 to 1.43 | 1.38 to 1.46 |
| EPYC 9V74, Zen 4 (3) | 0.84 to 1.11 | 1.19 to 1.58 | 1.36 to 1.42 |
| EPYC 9V45, Zen 5 (2) | 0.77 to 0.87 | 1.08 to 1.20 | 1.36 to 1.53 |
| Xeon 6973P-C (1) | 0.92 to 1.03 | 1.11 to 1.27 | 1.20 to 1.23 |
| Neoverse-N2 (2) | 1.57 to 1.86 | 1.85 to 2.28 | 1.17 to 1.23 |
100,000 vectors:
| CPU (machines) | Before | After | Speedup |
|---|---|---|---|
| EPYC 7763, Zen 3 (6) | 1.08 to 1.25 | 1.29 to 1.58 | 1.15 to 1.30 |
| EPYC 9V74, Zen 4 (3) | 1.41 to 1.76 | 1.92 to 2.34 | 1.33 to 1.42 |
| EPYC 9V45, Zen 5 (2) | 1.45 to 1.60 | 1.91 to 2.10 | 1.30 to 1.35 |
| Xeon 6973P-C (1) | 0.85 to 0.97 | 0.98 to 1.19 | 1.15 to 1.24 |
| Neoverse-N2 (2) | 2.43 to 2.96 | 2.94 to 3.55 | 1.16 to 1.29 |
A million vectors:
| CPU (machines) | Before | After | Speedup |
|---|---|---|---|
| EPYC 7763, Zen 3 (6) | 0.80 to 1.01 | 0.96 to 1.12 | 1.06 to 1.20 |
| EPYC 9V74, Zen 4 (3) | 1.41 to 1.65 | 1.84 to 2.02 | 1.21 to 1.36 |
| EPYC 9V45, Zen 5 (2) | 1.58 to 1.71 | 2.15 to 2.34 | 1.35 to 1.40 |
| Xeon 6973P-C (1) | 1.14 to 1.15 | 1.30 to 1.33 | 1.13 to 1.15 |
| Neoverse-N2 (2) | 2.48 to 3.00 | 3.19 to 3.59 | 1.13 to 1.30 |
Decisions 1 to 6 alone, in earlier runs of the same kind, on the two Xeons the final code did not meet:
| CPU | Vectors | Before | After |
|---|---|---|---|
| Xeon 8573C (2) | 2,000 and 20,000 | 0.85 to 0.99 | 1.09 to 1.25 |
| 100,000 | 0.89 to 1.14 | 1.07 to 1.42 | |
| a million | 1.12 to 1.27 | 1.31 to 1.44 | |
| Xeon 8370C (1) | 2,000 and 20,000 | 0.85 to 0.98 | 1.07 to 1.29 |
| 100,000 | 1.11 to 1.15 | 1.28 to 1.37 | |
| a million | 1.05 to 1.08 | 1.13 to 1.19 |
What each decision gave
Each commit against the one before, as the median over ef and six x86 machines (decision 7
over twelve):
| Decision | 2,000 | 20,000 | 100,000 |
|---|---|---|---|
| 1. Loops compiled per kernel | +1% | +1% | 0% |
| 2. Errors out of line | +3% | +3% | +3% |
| 3. One integer per heap entry | +20% | +15% | +11% |
| 4. Squared distances | +3% | +4% | +3% |
| 5. Visited without a branch | 0% | +5% | +2% |
| 6. Hints worked out once | +6% | +7% | +7% |
| 7. Hints in the upper layers | −2% | −1% | +1% |
Decision 3 gave 12 to 27% across machines and ef at 2,000 vectors. Decision 5 was worth 2 to
10% at 20,000 and cost up to 3% at 2,000, the more the larger ef: few words are still empty
there, and the branch it removes was easy to predict. Decision 7 is for the large index: 3% at a
million vectors on x86 in the median, up to 11% at ef = 32, and 1 to 27% on Neoverse-N2. On an
index that sits in cache its hints buy nothing and cost 1 to 3% at ef = 32, 5% on one machine.
On Neoverse-N2 decision 3 gave 7 to 12% and no other more than 5%.
Builds
One thread, 100,000 vectors, in seconds: the best of two runs on each machine.
| CPU | hnswlib | Before | After |
|---|---|---|---|
| EPYC 7763 (6) | 19.7 to 22.5 | 18.4 to 19.9 | 14.7 to 17.2 |
| EPYC 9V74 (3) | 19.0 to 22.8 | 15.9 to 19.5 | 12.6 to 15.0 |
| EPYC 9V45 (2) | 13.0 to 14.8 | 12.1 to 12.2 | 9.6 to 9.9 |
| Xeon 6973P-C (1) | 14.2 | 14.5 | 12.0 |
| Neoverse-N2 (2) | 36.9 to 42.4 | 18.8 to 19.7 | 16.9 |
- In cache Chassis now leads hnswlib on every x86 processor measured, by 7% or more, where it trailed by up to 23%. Search there is 1.2 to 1.5 times faster than before.
- At a million vectors search is 1.06 to 1.4 times faster. Zen 3, the one processor where Chassis trailed at that size, is level: 0.96 to 1.12.
- One-thread builds are 1.1 to 1.3 times faster, and ahead of hnswlib’s on every machine.
What Was Tried and Left Out
- Leaving a visited node’s word unwritten. Decision 5 writes the word back even when the bit
was already set. Returning early instead gave 2 to 3% at 2,000 vectors with a large
efand cost about 3% at 100,000 on Zen 3 and Zen 4, up to 6, most likely because the compiler then folds the two tests back into the branch. - Forcing the neighbor iterator inline. Within two percent either way on x86, and 1 to 4% slower on Neoverse-N2 below a million vectors.
- A prefetch policy for small indexes. In the new loop eight lines per vector is the best setting or level with it at every size. No hints at all are level to 5% slower at 2,000 vectors, 7 to 10% slower at 20,000 and 11 to 44% slower at 100,000 on x86; on Neoverse-N2 10, 29 and 46%.
- Hints into L1 on Granite Rapids. On the one Xeon 6973P-C that ran it, L1 was 2% faster than L2 at 2,000 vectors, 3% at 20,000 and about 10% at 100,000. Ice Lake preferred L2 slightly and Emerald Rapids did in ADR-0013. One machine, and nothing at a million vectors: not enough to add a second exception to the policy.
- Skipping the upper layers’ hints on a small index. It would win back what decision 7 costs there, with a threshold that depends on the machine’s caches.
Consequences
Positive
- Chassis is no longer behind hnswlib where the index fits in cache, on any CPU measured.
- Search and one-thread builds are faster at every size, on x86 and on ARM.
- Results at equal distances now come in a defined order, and a NaN distance can’t come first.
Negative
- Results are not the same bit for bit as before: the order of equal distances, and at a million vectors a few candidates taken or left that were not.
- The search loop is compiled twice on x86, once per kernel, and the binary grows by that.
- A packed entry holds a 32-bit slot. The format already limits an index to 2³² − 1 slots.
- The upper layers’ hints cost a small index 1 to 3% at a small
ef. - The measurements are one dataset at 128 dimensions on shared runners (ADR-0015 has 960 and 1,536).
ADR-0015: Prefetch Depth for Long Vectors
Date: 2026-10-09 Status: Proposed
Context
ADR-0013 and ADR-0014 were measured on SIFT, at 128 dimensions. A 128-dimension vector is 512 bytes, so the eight cache lines a search asks for ahead are the whole vector. Of a 1,536-dimension vector they are a twelfth.
The same experiment branch ran both engines at 960 dimensions (GIST, 200,000 of its vectors) and
at 1,536 (DBpedia entities embedded with OpenAI’s text-embedding-3-large, 99,000 vectors), with
hnswlib compiled on the machine with -march=native. ADR-0014’s code against hnswlib:
| CPU | 960, 20,000 | 960, 200,000 | 1,536, 2,000 | 1,536, 20,000 | 1,536, 99,000 |
|---|---|---|---|---|---|
| Neoverse-N2 | 1.81 to 2.30 | 1.36 to 1.53 | 2.84 to 3.03 | 1.42 to 1.97 | 1.44 to 1.63 |
| EPYC 7763 (Zen 3) | 0.89 to 1.01 | 0.86 to 0.90 | 1.25 to 1.43 | 0.82 to 0.94 | 0.76 to 0.85 |
| EPYC 9V74 (Zen 4) | 0.73 to 0.82 | 0.76 to 0.87 | 1.07 to 1.10 | 0.66 to 0.67 | 0.62 to 0.73 |
| EPYC 9V45 (Zen 5) | 0.88 to 0.93 | 0.85 to 0.91 | 0.80 to 0.82 | ||
| Xeon 8573C | 0.87 to 0.90 | 0.86 to 0.88 | |||
| Xeon 6973P-C | 0.81 to 0.83 |
Chassis leads on ARM, where hnswlib has no SIMD kernel, and in cache. Out of cache on x86 it trails, by more than at 128 dimensions, and ADR-0014’s changes gave about 5% there where on SIFT they gave 15% or more nearly everywhere: they are to the loop around the kernel, and at these sizes the kernel and the memory under it are most of the time. The kernels themselves are level, within 8% of each other at 960 dimensions on every x86 machine.
Two things were found.
Huge pages. hnswlib’s index is anonymous memory, which these runners back with 2 MB pages; Chassis’s is a file mapping on 4 KB pages. With huge pages turned off hnswlib is 4 to 9% slower on the Xeons, 10 to 16% on Zen 5, and 17 to 28% on Zen 3 and Zen 4 out of cache. That is most of the gap, and the last section is about it.
Depth. How much of a long vector to ask for depends on the CPU. Each depth against eight
lines, all into L2, as the median over ef and over index sizes of 20,000 and up:
| CPU | 16 lines | 32 lines | 64 lines | Whole vector |
|---|---|---|---|---|
| Apple M5, 1,536 | +1 to 5% | +4 to 13% | +10 to 13% | +61 to 68% |
| Neoverse-N2 | +6 to 11% | +5 to 11% | +6 to 19% | −2 to +17% |
| EPYC 9V74 (Zen 4) | +1 to 3% | +3 to 9% | +5 to 21% | +6 to 23% |
| EPYC 7763 (Zen 3) | 0% | −3 to −4% | −9 to −17% | −10 to −17% |
| EPYC 9V45 (Zen 5) | 0 to +3% | −3 to 0% | −17% | |
| Xeon 8573C | −3 to +1% | −4 to −12% | −8 to −17% | −8 to −16% |
| Xeon 6973P-C | −4% | −7% | +2% |
On the M5 only the whole vector pays: at 3,072 dimensions three quarters of it gave 5% and all of it 27 to 31%. Zen 3, Zen 5 and the Xeons fetch the rest of a vector well enough themselves, and more hints only get in the way.
Decision
A search asks for more of a long vector where the CPU does better with it:
| CPU | Lines asked for |
|---|---|
| Apple silicon | The whole vector, up to 256 lines (4,096 dimensions) |
| Other aarch64 | 16 |
| AMD family 19h, a Zen 4 model | 32 |
| Everything else | 8, as before |
Into L2, and on Zen 5 into L1, as before. Zen 4 is told from Zen 3, which shares its CPUID family, by the model ranges Linux uses: 10h to 1Fh and 60h to AFh. The two ARM policies are chosen when Chassis is compiled.
A vector of 128 dimensions is eight lines, so nothing changes there.
Measurements
On 2026-10-09, on GitHub’s runners (4 vCPUs) and one Apple M5 in Low Power Mode. M = 16,
ef_construction = 200, search on one thread over 1,000 queries as the median of five passes
(three on the M5), each engine run four times per job on the runners and three times on the M5,
every version searching the same index. A range covers ef 32 to 256 and every machine of that kind.
The code of ADR-0014 and the code of this one, where the policy changed:
| CPU (machines) | Vectors | Before, against hnswlib | After | Change |
|---|---|---|---|---|
| Neoverse-N2 (2) | 1,536 × 2,000 | 2.84 to 3.03 | 3.16 to 3.23 | +6 to 12% |
| 1,536 × 20,000 | 1.64 to 1.97 | 1.68 to 1.94 | −8 to +16% | |
| 1,536 × 99,000 | 1.44 to 1.59 | 1.55 to 1.70 | +6 to 14% | |
| Neoverse-N2 (2) | 960 × 20,000 | 1.82 to 2.30 | 1.91 to 2.47 | −2 to +10% |
| 960 × 200,000 | 1.44 to 1.53 | 1.54 to 1.64 | +7 to 11% | |
| EPYC 9V74 (1) | 1,536 × 2,000 | 1.07 to 1.10 | 1.04 to 1.10 | −2 to −1% |
| 1,536 × 20,000 | 0.66 to 0.67 | 0.69 to 0.70 | +4 to 6% | |
| 1,536 × 99,000 | 0.63 to 0.64 | 0.66 to 0.68 | +4 to 5% | |
| Apple M5 | 1,536 × 20,000 | +66 to 80% | ||
| 1,536 × 99,000 | +54 to 58% | |||
| 3,072 × 20,000 | +24 to 31% |
The 3,072-dimension vectors are pairs of the 1,536-dimension ones joined end to end. One of the two Neoverse-N2 machines was noisy at 20,000 vectors, for every version it ran.
Where the policy did not change, three Zen 3 machines, a Zen 5 and a Xeon 8573C, and at 128 dimensions on all of them, the two versions were within 2% of each other in the median.
One-thread builds of 20,000 vectors took 28 to 35 s where they took 30 to 38 on Neoverse-N2 at 1,536 dimensions, 10.0 to 11.4 s against 10.8 to 12.1 at 960, and 27 s against 29 on Zen 4.
- On ARM a search over long vectors is 4 to 9% faster in the median, and on Apple silicon at 1,536 dimensions half as fast again or more.
- On Zen 4 it is 5% faster out of cache and still well behind hnswlib there.
- On Zen 3, Zen 5 and Intel nothing changes. At 960 and 1,536 dimensions out of cache Chassis runs at 0.65 to 0.93 of hnswlib’s speed on x86.
Huge Pages: Measured, Not Decided
Asking for huge pages on the mapping, madvise(MADV_HUGEPAGE), works on the runners: Linux 6.17
with ext4 put nearly the whole file on 2 MB pages, whether it had been read back from disk or
had just been built. It is not in this ADR’s decision because of what it does to a flush.
Search, against ADR-0014’s code in the same job, as the median over ef:
| CPU | SIFT, a million | 960 × 200,000 | 1,536 × 99,000 |
|---|---|---|---|
| EPYC 7763 (Zen 3) | +16% | +11% | |
| EPYC 9V74 (Zen 4) | +9% | +22% | +30% |
| EPYC 9V45 (Zen 5) | +12% | +10% | |
| Xeon 6973P-C | +6% | ||
| Neoverse-N2 | +22% | +7% | +7% |
With them Chassis is at 0.93 to 1.10 of hnswlib’s speed at 960 dimensions on the three AMD processors and 0.81 to 0.95 at 1,536. On the vectors alone, leaving the graph on small pages, the gain is two thirds to all of that. Builds were 2 to 14% faster, and the first pass over a file not yet in memory was no slower.
The cost is in adding one vector and flushing, as the median of 300:
| Small pages | Huge pages on everything | On the vectors only | |
|---|---|---|---|
| Milliseconds | 1.6 to 7.3 | 47 to 156 | 3.5 to 41 |
The figures fit a page written through a mapping being written back whole. One add changes a few neighbor lists scattered over the graph, and each is then 2 MB to write. With only the vectors on huge pages it is the page the new vector lands on.
What would keep the gain and not the cost: readers, whose mappings are never written back, ask for huge pages on everything; a writer asks for them on the vectors only, and only on the 2 MB stretches that a batch is about to fill or that are already committed, so that the stretch being appended to stays on small pages; and an option turns it off, for an index much larger than memory, where each miss would read 2 MB. That is its own change and its own ADR: ADR-0016, which has readers ask for the vectors only, as writers do, since the pages are shared.
What Was Tried and Left Out
- Hints at the page starts inside a vector. A 4 KB page boundary stops the hardware prefetcher, and most long vectors cross one. One to four lines at each later page start gave 2 to 4% on Zen 4 and nothing consistent on Zen 3 or Neoverse-N2.
- 64 lines on Zen 4. 10% at 20,000 vectors of 1,536 dimensions where 32 gave 5%, but 5 to 9% slower than eight lines at 2,000.
- 32 or 64 lines on Neoverse-N2. 64 was the best setting at 960 dimensions and 200,000 vectors, 8 to 17%, and gave nothing at 2,000 vectors of 1,536. Sixteen was the steadiest.
- L1 for long vectors. Within 2% of L2 on Zen 3, Zen 4 and Zen 5; 1 to 7% slower on the Xeon 6973P-C.
Consequences
Positive
- Long vectors search faster on ARM, on Apple silicon by half or more.
- Nothing changes at 128 dimensions, or on a CPU whose policy did not change.
Negative
- The policy is now four rules fitted to seven CPUs. A CPU that was not measured gets eight lines.
- Telling Zen 4 from Zen 3 depends on model numbers.
- Out of cache on x86, Chassis is still behind hnswlib at these dimensions until its mapping is on huge pages.
- Apple’s policy was measured on one machine, and nothing here above 3,072 dimensions.
ADR-0016: Huge Pages for the Vectors, on Request
Date: 2026-10-09 Status: Proposed
Context
ADR-0015 found that most of what hnswlib has over Chassis on a large index of long vectors on x86 is the size of its pages. Its index is anonymous memory, which Linux backs with 2 MB pages; Chassis’s is a file mapping on 4 KB pages.
Linux can keep a file on huge pages too, where the filesystem supports it, and
madvise(MADV_HUGEPAGE) on the mapping asks for it. ADR-0015 measured what that gives, 6 to 30%
on a search, and what it costs: adding one vector and flushing went from about 3 ms to 47 to
156 ms with every region on huge pages, and to 3.5 to 41 ms with only the vectors on them. A page
written through a mapping is written back whole, so one changed neighbor list, or one appended
vector, became 2 MB to write.
Two more things about the kernel decide what can be asked for safely, both from how Linux 6.17
reads a file on a miss in a range that asked for huge pages (do_sync_mmap_readahead):
- A huge page is a piece of the file, not of a mapping. The miss is rounded down to a multiple of 2 MB in the file, and the page read there goes into the page cache, which every process that maps the file shares. So a huge page a reader caused is the page a writer writes through.
- A miss reads ahead. The kernel fetches that huge page and the next one, unless the range is marked as read at random.
Decision
1. Off unless asked for
IndexOptions::huge_pages turns it on for a writer or a reader; use_huge_pages() on either
handle does the same after opening, as do chassis_use_huge_pages in C and
IndexOptions(huge_pages=True) in Python. Each process asks for itself.
It is a request. On Linux it is madvise on parts of the mapping; a kernel or filesystem that
doesn’t keep files on huge pages ignores it, and on other systems nothing is asked.
2. Only the vectors, and only whole huge pages of them that will not be written again
Chassis asks for a 2 MB piece of the file only if every byte of it is a vector that is either committed, and so never written again, or about to be written by a batch, which writes its vectors once. A piece that also holds slot headers, neighbor lists, or the stretch of vectors that single adds are appending to is never asked for.
So the graph stays on small pages, and so does the end of the vectors. Nothing a flush has to write back is on a huge page, except a batch’s own vectors, which it writes in full anyway.
3. Random access with it
Each range asked for is also marked MADV_RANDOM, so that a miss reads its own huge page and
not the next one, which may be a piece decision 2 left out. A search reads vectors in no order
anyway.
4. When it asks
At open, for the committed vectors. At each commit, and in a reader at each snapshot, for
vectors committed since. And before a batch writes its vectors, for the pieces it will fill, so
that an index built by add_batch is on huge pages from the start without being read back from
disk.
5. Readers ask for the same pieces as writers
A reader’s mapping is never written back, but its huge pages are the writer’s too. Asking only for committed vectors, a reader can’t slow the writer’s flushes.
Measurements
On 2026-10-09, on GitHub’s runners (4 vCPUs, Linux 6.17, ext4), two runs of twelve jobs. The
code of ADR-0015 and this code with the option off and on, in the same job, on SIFT (128
dimensions, a million vectors), GIST (960, 200,000) and DBpedia with OpenAI embeddings (1,536,
99,000). M = 16, ef_construction = 200, search on one thread over 1,000 queries as the
median of five passes, three runs each. A range covers ef 32 to 256 and every machine of that
kind.
With the option on, 95 to 97% of the vectors were on huge pages, whether the file had just been built by a batch or had been read back from disk. With it off, none were, and the new code ran within 3% of the old in the median on all but one noisy machine.
Search with the option on against the same index with it off, as the median over ef and the
range:
| CPU (machines) | 128 × 1,000,000 | 960 × 200,000 | 1,536 × 99,000 |
|---|---|---|---|
| EPYC 7763, Zen 3 (7) | +3% (2 to 9) | +13% (11 to 17) | +13% (11 to 14) |
| EPYC 9V74, Zen 4 (5) | +11% (7 to 15) | +18% (16 to 18) | +27% (25 to 28) |
| EPYC 9V45, Zen 5 (5) | −2% (−7 to +5) | +13% (8 to 20) | +9% (8 to 10) |
| Xeon 8573C (1) | +5% (4 to 8) | ||
| Neoverse-N2 (6) | +14% (10 to 15) | +11% (4 to 15) | +5% (2 to 9) |
A reader that asked gained the same, within four points on every machine.
Against hnswlib, which has huge pages on these machines without asking:
| CPU | 960 × 200,000, off | On | 1,536 × 99,000, off | On |
|---|---|---|---|---|
| EPYC 7763 (Zen 3) | 0.87 to 0.90 | 0.99 to 1.03 | 0.77 to 0.81 | 0.87 to 0.92 |
| EPYC 9V74 (Zen 4) | 0.92 to 0.94 | 1.08 to 1.11 | 0.65 to 0.67 | 0.82 to 0.86 |
| EPYC 9V45 (Zen 5) | 0.87 to 0.92 | 0.96 to 1.05 | 0.81 to 0.83 | 0.88 to 0.91 |
| Neoverse-N2 | 1.45 to 1.52 | 1.58 to 1.68 | 1.54 to 1.68 | 1.59 to 1.76 |
Adding one vector and flushing, as the median of 300 in milliseconds, over every machine:
| The file | Option off | On |
|---|---|---|
| Just built by a batch | 1.7 to 7.2 | 1.5 to 5.5 |
| Read back from disk | 1.5 to 5.6 | 1.5 to 5.8 |
| Read back by a reader that asked, then written by a writer that didn’t | 1.3 to 5.5 |
Batch builds took 1 to 9% less time with the option on. The first pass of queries over a file not yet in memory took up to 28% less, and on one machine 1% more.
- Search over a large index is 3 to 27% faster in the median, most where vectors are long and on Zen 4.
- Except on Zen 5 at 128 dimensions, where on two machines it gained nothing or lost up to 7%. Why was not looked into.
- A flush costs what it did.
- At 960 dimensions Chassis is level with hnswlib on x86 with the option on, or ahead. At 1,536 it is still 8 to 18% behind.
What It Does Not Do
- Vectors added one at a time go onto small pages, and stay there while the file stays in memory: the kernel keeps the pages it has. They move to huge pages when the file is next read from disk.
- The graph is never on huge pages. ADR-0015 measured a third more gain at 128 dimensions with it there, and flushes 10 to 90 times slower.
- 3 to 5% of the vectors stay on small pages: the 2 MB at each end of a segment’s vectors that they share with something else.
- A batch that fails, or is lost in a crash, leaves its slots asked for. The adds that reuse them write into huge pages, and their flushes write 2 MB until those slots are full again.
- It was measured on one kernel and one filesystem. Elsewhere it may do nothing, or only make the kernel read 2 MB around a miss.
What Was Tried and Left Out
- Huge pages under everything, and under the vectors without the rule of decision 2: the flush costs ADR-0015 measured.
- Readers asking for everything. Their mappings are never written back, but the pages are shared, so a reader would have put the writer’s graph on huge pages.
- On by default. With it a page not yet in memory is read 2 MB at a time. An index that fits in memory reads the same bytes sooner; one much larger than memory reads far more than it uses, on every miss.
Consequences
Positive
- An index too large for the CPU’s caches searches faster on every CPU measured but one, by a quarter on Zen 4 with long vectors, and builds and warms up a little faster.
- No cost to durability: flushes write what they wrote before.
- Nothing changes for anyone who doesn’t ask.
Negative
- It depends on the kernel and the filesystem, and Chassis can’t tell whether it was granted;
FilePmdMappedin/proc/<pid>/smapsshows it. - An index built by single adds gains only after it has been read back from disk.
- On Zen 5 with short vectors it gave nothing or cost a little.
- The file’s vectors are cached and evicted 2 MB at a time, which costs an index much larger than memory.
- One more option, in four APIs.
ADR-0017: Compare at Equal Recall; Long Vectors Out of Cache Are Bound by Memory
Date: 2026-10-09 Status: Proposed
Context
Chassis is for local semantic search in one file. Someone searching picks the recall they need,
not an ef.
ADR-0013 to ADR-0016 compared Chassis with hnswlib at the same ef. At the same ef Chassis
returns more of the true neighbors, and computes more distances to do it: on 99,000 OpenAI
embeddings of 1,536 dimensions, recall of 0.938, 0.979, 0.993 and 0.998 at ef 32 to 256 where
hnswlib has 0.925, 0.969, 0.987 and 0.996. So those figures set a faster, less exact search
beside a slower, more exact one, and at 960 and 1,536 dimensions they showed Chassis behind on
x86.
Decision
1. Engines are compared at equal recall
Chassis’s speed is taken at the other engine’s recall, between the two ef values whose recall
lies on either side of it, on a straight line through the logarithms of the miss rate and of the
queries per second.
2. Nothing more is done to the search over long vectors that keeps them as they are
Two changes were tried for it and are left out. With full-precision vectors, an index of long vectors that doesn’t fit in cache is bound by how fast memory delivers them.
Measurements
From the runs of ADR-0015 and ADR-0016 and one more of the same kind, on 2026-10-09: GitHub’s
runners, one thread, hnswlib compiled with -march=native in the same job. Chassis against
hnswlib, out of cache, with huge_pages off:
| CPU | 128 × 1,000,000, same ef | Same recall | 960 × 200,000, same ef | Same recall | 1,536 × 99,000, same ef | Same recall |
|---|---|---|---|---|---|---|
| EPYC 7763 (Zen 3) | 1.06 to 1.11 | 1.15 to 1.31 | 0.83 to 0.90 | 0.91 to 1.01 | 0.75 to 0.83 | 0.92 to 1.15 |
| EPYC 9V74 (Zen 4) | 1.85 to 2.04 | 2.01 to 2.43 | 0.92 to 0.94 | 0.99 to 1.01 | 0.65 to 0.68 | 0.79 to 0.93 |
| EPYC 9V45 (Zen 5) | 2.17 to 2.33 | 2.37 to 2.72 | 0.86 to 0.92 | 0.92 to 1.03 | 0.81 to 0.83 | 0.99 to 1.10 |
| Xeon 8370C | 1.15 to 1.21 | 1.32 to 1.38 | 0.84 to 0.88 | 1.05 to 1.15 | ||
| Xeon 8573C | 1.32 to 1.48 | 1.48 to 1.72 | ||||
| Neoverse-N2 | 2.87 to 3.56 | 3.12 to 4.06 | 1.45 to 1.68 | 1.55 to 1.86 | 1.54 to 1.73 | 1.89 to 2.32 |
With huge_pages on, at the same recall: 1.05 to 1.20 at 960 dimensions and 1.01 to 1.25 at
1,536 on the three AMD processors.
- At equal recall Chassis is level with hnswlib on long vectors on x86, from 9% behind to 15% ahead, and ahead with huge pages. Zen 4 at 1,536 dimensions without them is the exception: 7 to 21% behind.
- At 128 dimensions its lead is a tenth to a fifth larger than the same-
effigures said.
Why there is little left
On one Zen 3 machine a query at ef = 64 over the 1,536-dimension index took 643 µs. A search
like it computes about 1,500 distances, counted on another build of the same index: some 430 ns
each, for 6 KB of vector, or 14 GB a second. The kernel alone needs 157 ns when the vectors are
in cache. The rest is waiting for memory.
What Was Tried and Left Out
- Stopping a distance once it is past the worst result. A sum of squares only grows, so the result is exact. Of the distances a search computes over these embeddings, 85% are for nodes it then turns away, but they pass the limit late: 87 to 90% of the floats are still read. The kernel looked at its running sum every 256 floats. At 1,536 dimensions out of cache that was 2% faster on Zen 3, 5% on Zen 4 and the Xeon 8370C, 9% on Neoverse-N2; in cache 3 to 6% slower on three of the four; and at 960 dimensions 8 to 9% slower on Zen 5. The looking costs about what the stopping saves.
- A kernel that asks for the vector ahead of where it is reading, 4, 8 or 16 lines. Against the same build without it: 3 to 6% faster on Zen 4 and Zen 5, within 5% either way on Zen 3, from 4% slower to 8% faster on the Xeon 8370C, and 4 to 9% slower on Neoverse-N2.
What would move a search over long vectors by more than a few percent is fewer bytes per vector: half-precision or 8-bit storage. That changes the file format and, for 8 bits, the results. It is not decided here; ADR-0018 adds half precision.
Consequences
Positive
- The figures now answer the question a user has: how fast at the recall I need.
- Two changes that cost code and paid little, or cost speed elsewhere, are not carried.
Negative
- The same-
eftables of ADR-0013 to ADR-0016 understate Chassis by a tenth to a quarter and stay as they were measured. - Equal-recall figures are interpolated between four
efvalues, and cover only the recalls both engines reach within them. - Zen 4 without huge pages stays behind at 1,536 dimensions.
ADR-0018: Half Precision for the Vectors, on Request
Date: 2026-10-09 Status: Proposed
Context
ADR-0017 found that a search over long vectors that don’t fit in cache waits for memory, and that
what would move it is fewer bytes per vector. The same bytes are the size of the file: a million
vectors of 1,536 dimensions are 6.1 GB of f32s, on a laptop whose memory they share with
everything else. Chassis is for local semantic search in one file, so that is the tighter limit.
An IEEE 754 half-precision float is 16 bits: 11 significant bits, about three decimal digits, and magnitudes up to 65,504. A component of an embedding is of order 0.01 to 1, and two vectors that a search has to tell apart differ by far more than a part in two thousand.
Before anything was built, the experiment branch rounded real vectors to half precision and back and searched them as before. Exact search over the rounded vectors found 100% of the true ten nearest on SIFT (whose components are whole numbers, which half precision holds), 100% on 99,000 OpenAI embeddings of 1,536 dimensions, and 99.9% on 200,000 GIST vectors of 960. An index built over them had the recall of one built over the originals, within what two builds of the same index differ by.
Decision
1. A precision, chosen when the index is created
IndexOptions::precision is Precision::Full, the default, or Precision::Half; in C the last
argument of chassis_open_with_precision, in Python IndexOptions(precision="half"). It is the
file’s from then on, as the metric is: a writer that opens the file names it and is refused if it
names another, a reader takes it from the file, and precision() reports it. A compaction writes
its copy in the same precision.
2. The file keeps 16-bit floats
Each component is rounded to the nearest half, ties to even, and stored in two bytes, so a vector
is dims × 2 bytes where it was dims × 4. Nothing else in the file changes size.
A finite component of 65,520 or more in magnitude would round to infinity. A vector with one is refused, and a batch with one is refused whole, before any of it is written. A cosine index stores unit vectors, which always fit.
3. A query stays in f32
A distance is from the query’s f32s to a stored vector widened as it is read: eight components
at a time with F16C on x86, four with fcvtl on aarch64, one by one elsewhere. The sums are the
ones the f32 kernel computes, in the same order, so over values that half precision holds the
two give the same bits.
The x86 kernel needs AVX2, FMA and F16C; a CPU with the first two and not the third gets the scalar kernel for an index in half precision, and keeps the AVX2 kernel for one in full.
4. A vector is linked as kept
A new vector’s neighbors are found from the vector the file kept, read back and widened, not from the one given, so that a single add and a batch measure the same distances. Choosing between two stored vectors widens one of them and measures as a search does. Reported distances are to the vectors as kept.
5. Such a file is format 4
A release from before this one would read half-precision vectors as f32s, so it has to refuse
the file: its header says format 4 to readers and to writers. A full-precision file is written
as format 3, byte for byte as before, and older releases go on reading and writing it. The
precision is one byte of the header that was always zero.
6. Off unless asked for
The results are not quite the ones full precision gives, the file can’t be read by older releases, and on one CPU measured an index that fits in cache searches more slowly. Each is small; none is for Chassis to choose on a user’s behalf.
Measurements
On 2026-10-09, on GitHub’s runners (4 vCPUs, Linux 6.17, ext4), two runs of 17 jobs, and on one
Apple M5 in Low Power Mode. SIFT (128 dimensions), GIST (960) and DBpedia with OpenAI embeddings
(1,536). M = 16, ef_construction = 200, search on one thread over 1,000 queries as the median
of five passes, three runs each on the runners and five on the M5. Both precisions were searched
by the same build, each over its own index of the same vectors. A range covers ef 32 to 256 and
every machine of that kind.
Recall. At each ef, on each machine, half precision’s recall less full precision’s:
| Vectors | Least | Most | Mean |
|---|---|---|---|
| 128 × 1,000,000 | −0.0016 | +0.0031 | +0.0001 |
| 960 × 200,000 | −0.0028 | +0.0053 | +0.0003 |
| 1,536 × 99,000 | −0.0029 | +0.0015 | 0.0000 |
Two builds of one full-precision index differed by up to 0.004.
Size. The file is 51 to 52% of what it was at 960 and 1,536 dimensions, 821 MB to 421 MB for the 99,000 embeddings, and 62 to 63% at 128, where the graph is a larger share.
Search. Queries per second in half precision over full, as the median over ef:
| CPU (machines) | 128 × 20,000 | 128 × 1,000,000 | 960 × 20,000 | 960 × 200,000 | 1,536 × 2,000 | 1,536 × 20,000 | 1,536 × 99,000 |
|---|---|---|---|---|---|---|---|
| EPYC 7763, Zen 3 (9) | 1.00 | 1.46 | 1.36 | 1.28 | 1.10 | 1.28 | 1.23 |
| EPYC 9V74, Zen 4 (6) | 1.00 | 1.09 | 2.37 | 2.27 | 1.13 | 1.63 | 1.62 |
| EPYC 9V45, Zen 5 (4) | 1.06 | 1.10 | 1.54 | 1.10 | 1.11 | 1.13 | 1.02 |
| Xeon 8370C (1) | 1.42 | ||||||
| Xeon 8573C (3) | 1.10 | 1.28 | 1.41 | ||||
| Xeon 6973P-C (1) | 1.50 | ||||||
| Neoverse-N2 (10) | 1.01 | 1.09 | 1.11 | 1.41 | 0.81 | 1.28 | 1.26 |
| Apple M5 (1) | 1.25 to 2.39 | 1.46 to 1.74 | 1.52 to 1.69 |
With huge pages (ADR-0016), in the second run, each file read back from disk first. Against full precision on small pages:
| CPU | Vectors | Huge pages | Half precision | Both |
|---|---|---|---|---|
| Zen 3 | 128 × 1,000,000 | 1.06 | 1.45 | 1.49 |
| 960 × 200,000 | 1.18 | 1.28 | 1.39 | |
| 1,536 × 99,000 | 1.15 | 1.24 | 1.30 | |
| Zen 4 | 1,536 × 99,000 | 1.28 | 1.63 | 1.57 |
| Zen 5 | 128 × 1,000,000 | 0.99 | 1.07 | 1.10 |
| Xeon 8370C | 960 × 200,000 | 0.99 | 1.44 | 1.42 |
| Xeon 8573C | 128 × 1,000,000 | 1.05 | 1.27 | 1.42 |
| 1,536 × 99,000 | 1.04 | 1.42 | 1.40 | |
| Xeon 6973P-C | 128 × 1,000,000 | 1.00 | 1.50 | 1.64 |
| Neoverse-N2 | 128 × 1,000,000 | 1.07 | 1.09 | 1.31 |
| 960 × 200,000 | 1.04 | 1.40 | 1.40 | |
| 1,536 × 99,000 | 1.06 | 1.29 | 1.32 |
Against hnswlib at its recall (ADR-0017), on small pages. hnswlib keeps f32s, and has huge
pages on these machines without asking:
| CPU | 128 × 1,000,000, full | Half | 960 × 200,000, full | Half | 1,536 × 99,000, full | Half |
|---|---|---|---|---|---|---|
| Zen 3 | 1.23 to 1.32 | 1.75 to 1.96 | 0.91 to 1.03 | 1.16 to 1.27 | 0.94 to 1.11 | 1.13 to 1.40 |
| Zen 4 | 2.26 to 2.46 | 2.47 to 2.67 | 0.86 to 0.95 | 1.94 to 2.13 | 0.82 to 1.01 | 1.33 to 1.69 |
| Zen 5 | 2.16 to 2.56 | 2.54 to 2.94 | 0.97 to 1.01 | 1.02 to 1.09 | 0.99 to 1.13 | 1.00 to 1.11 |
| Xeon 8370C | 0.99 to 1.05 | 1.42 to 1.49 | ||||
| Xeon 8573C | 1.52 to 1.69 | 1.95 to 2.17 | 0.95 to 1.09 | 1.38 to 1.53 | ||
| Xeon 6973P-C | 1.45 to 1.56 | 2.10 to 2.30 | ||||
| Neoverse-N2 | 3.13 to 4.44 | 3.42 to 4.89 | 1.65 to 1.77 | 2.31 to 2.49 | 1.85 to 2.33 | 2.45 to 2.93 |
Builds. Time in half precision over full, as the range over machines of one batch build each and of the median of two one-thread builds:
| CPU | One thread, 20,000 × 960 | One thread, 20,000 × 1,536 | Batch, 200,000 × 960 | Batch, 99,000 × 1,536 | Batch, 1,000,000 × 128 |
|---|---|---|---|---|---|
| Zen 3 | 0.75 to 0.81 | 0.77 to 0.79 | 0.62 to 0.68 | 0.74 to 0.76 | 0.72 to 0.73 |
| Zen 4 | 0.60 to 0.63 | 0.62 to 0.69 | 0.51 to 0.52 | 0.65 to 0.67 | 0.83 |
| Zen 5 | 0.67 | 0.77 | 0.75 | 0.86 | 0.89 to 0.90 |
| Xeon 8370C | 0.69 | 0.69 | |||
| Xeon 8573C | 0.73 | 0.70 | 0.79 to 0.81 | ||
| Xeon 6973P-C | 0.69 | ||||
| Neoverse-N2 | 0.97 to 1.07 | 1.05 to 1.15 | 0.80 to 0.84 | 0.99 to 1.04 | 0.95 to 1.00 |
| Apple M5 | 0.69 |
One-thread builds of 20,000 vectors of 128 dimensions took 0.93 to 1.04 of the time everywhere.
Full precision. The same indexes searched by this code and by the code before it, in full precision: 0.998 in the median over 228 measurements, nine in ten within 3%.
The kernels alone, in an earlier run on eight machines, over vectors picked at random from
a gigabyte, sixteen at a time with their first eight lines asked for. In nanoseconds a distance
at 1,536 dimensions: 452 in f32 and 357 in half on Zen 3, 543 and 391 on Zen 4, 623 and 521 on
Neoverse-N2. From a quarter megabyte, where nothing waits for memory: 90 and 90, 75 and 75, 176
and 280.
- Recall is what it was.
- The file, and the memory it takes, is about half.
- A search over an index too large for the CPU’s caches is faster, by a tenth to two thirds on most machines and more than twice on Zen 4 at 960 dimensions, where the 32 lines it asks for ahead are now the whole vector. On Zen 5 at 1,536 dimensions and 99,000 vectors it gained nothing; why was not looked into.
- In cache it costs nothing on x86 and a fifth on Neoverse-N2 with long vectors, whose kernel alone takes 1.6 times as long there.
- Huge pages add little to it where vectors are long, from 3% less to 8% more at 960 and 1,536 dimensions, and 3 to 20% at 128.
- With long vectors, builds take 14 to 49% less time on x86. On Neoverse-N2 a batch build takes up to a fifth less and a one-thread build up to 15% more.
What It Does Not Do
- It doesn’t keep the vectors as given. There is nothing to re-rank from, and a vector read back from the file is the rounded one.
- It doesn’t convert an index. The precision is fixed at creation; to change it, the vectors are added to a new index.
- It doesn’t hold everything
f32does. Components of 65,520 or more in magnitude are refused, and ones below 3 × 10⁻⁸ become zero. Vectors of raw counts or unscaled features may not fit; embeddings do. - It isn’t quantization. Eight bits a component would halve the file again, and change results by more than rounding does. That is still not decided.
What Was Left Out
- On by default. Decision 6.
- Fewer level-0 neighbors for smaller files. 24 instead of 32 (lab
m0.yml, 2026-10-10, half precision, on Neoverse-N2, a Xeon 8573C and an EPYC 9V74): files 7% smaller at 128 dimensions and a million vectors, 3 to 4% at 384 and 1% at 1,536, and batch builds 9 to 18% faster, but searches for 0.99 recall 6 to 15% slower at 128 dimensions and 7 to 16% at 384, and 0.97 to 1.04 times as fast at 1,536.max_connections: 12gives the same, so there is nothing to build. - A kernel for two stored vectors. It would widen both sides of every distance where one side widened once serves a whole selection.
- Requiring F16C wherever AVX2 is used. One code path fewer, but an x86 CPU or emulator with AVX2 and no F16C would have lost its fast kernel for every index, full precision included.
Consequences
Positive
- An index takes half the disk and half the memory, which on a laptop decides how large an index can be.
- Searches out of cache and builds of long vectors are faster on every x86 CPU measured and on the one Apple CPU.
- At equal recall, an index in half precision too large for cache was level with hnswlib, which
keeps
f32s, or ahead of it on every machine measured: 1.00 to 1.69 times its speed at 1,536 dimensions on x86, where full precision was 0.82 to 1.13, and without huge pages. - Nothing changes for an index in full precision: its file, its results, its speed.
Negative
- A second file format version, and files that older releases refuse.
- A second set of kernels and of loops compiled around them.
- On Neoverse-N2 an index of long vectors that fits in cache searches a fifth more slowly, and one-thread builds of long vectors take up to 15% longer.
- Distances are to rounded vectors, so they differ a little from one precision to the other.
- One more option, in four APIs, and one that can’t be changed after creation.
ADR-0019: Reading an Index into Memory in the Background, on Request
Date: 2026-10-10 Status: Proposed
Context
An index opens in about a millisecond, because opening maps the file and reads only its headers. While the file is not in memory, as after a reboot, every page a search touches is read from the disk when the search reaches it. A search waits for each of those reads before it knows what to read next, so they come one at a time. On an Apple M5 the SSD serves 10,700 such reads a second one at a time and 114,000 with 32 in flight; macOS reads a 16 KB page per fault and Linux 128 KB. On 99,000 vectors of 1,536 dimensions the first result came 110 ms after the process started, and the hundredth 1.8 s after: slower than hnswlib, which reads its whole file in before it answers anything (794 ms for the first result, 982 ms for the hundredth).
A prototype on the experiment branch read the file in on another thread while searches went on, and the hundredth result came after 324 ms (ROADMAP.md, 2026-10-09).
Decision
1. An option and a method
IndexOptions::warm, off by default, reads the index in from the moment it is opened.
VectorIndex::warm and IndexReader::warm ask for the same later; in C, chassis_warm; in
Python, IndexOptions(warm=True) and VectorIndex.warm(). Asking turns the option on, so a reader
that opens the file again after a compaction reads the new one in too.
2. A thread that reads what was written
The thread is given the file ranges the index has written: each segment’s slot headers, vectors and level-0 lists up to the last slot, the heap chunks before the one in use, and that one up to its last entry. What lies past them was never written, and in a sparse file is a hole. On the 99,000-vector file that is 625 MB of 821 MB. The ranges are read in file order. While a reading is under way, asking again does nothing; once it is done, asking again reads what the index holds then.
3. Through the index’s own mappings, except a writer’s off Linux
The thread gives the system advice about the index’s own mappings, which then have the pages mapped as well as in memory: on a file already in memory that alone made the first hundred searches three times as fast on the M5. A reader’s mappings are read-only. A writer’s are writable, and on macOS advice to read pages in through a writable shared mapping marks every page it reads as changed, so the system writes them all back: 309 MB written for 320 MB read, on the half-precision file. So a writer off Linux, where only Linux promises to leave such pages clean, maps the file read-only for the thread and gives the advice about that view instead.
A reader unmaps a region when a restarted writer has laid the file out differently. It stops the thread first: advice about an address that is no longer mapped is an error, but about one that has been mapped again it acts on whatever is there now, and on macOS read untouched anonymous memory in, 64 MB of it in a test. Dropping or replacing an index stops the thread before its mappings go.
4. Each system asked in its own way
On Linux 5.14 and later the thread asks for MADV_POPULATE_READ, which reads the pages in before
it returns. Before 5.14 it falls back to MADV_WILLNEED, which the kernel honors only in part. On
macOS it asks for MADV_WILLNEED, which reads in all it is asked for. On Windows it does nothing
yet.
5. In steps, so that it can be stopped
The thread asks for 8 MiB at a time and checks between requests whether to stop; stopping waits for at most one request. A forked child that drops an index whose parent was reading it in does not wait for a thread it does not have.
6. Off unless asked for
An index much larger than memory would push everything else out, itself included, and an index already in memory gains nothing. Whether a file is in memory is the application’s to know.
Measurements
On an Apple M5 in Low Power Mode, 2026-10-10, 99,000 OpenAI embeddings of 1,536 dimensions
(an 821 MB file in full precision, 421 MB in half), ef 64, each run a fresh process opening the
index as a reader and searching 1,000 queries; milliseconds from the start of the process, medians
of three to five rounds:
| File out of memory | First result | 100 results | 1,000 results |
|---|---|---|---|
| Full precision | 110 | 1,890 | 2,400 |
Full, with warm | 110 | 365 | 864 |
| Half precision | 100 | 976 | 1,298 |
Half, with warm | 81 | 198 | 517 |
| hnswlib | 499 | 705 | 2,571 |
| File in memory | First result | 100 results | 1,000 results |
|---|---|---|---|
| Full precision | 9 | 252 | 732 |
Full, with warm | 9 | 85 | 571 |
| Half precision | 6 | 94 | 423 |
Half, with warm | 6 | 53 | 370 |
A writer, on a copy of the full-precision file out of memory, returned its hundredth result after
523 ms with warm and 1,912 ms without; through a view of the file it gains nothing when the file
is already in memory. Once a search’s pages are in, it takes as long either way: 0.52 to 0.55 ms.
What it reads in is what was written: 38,124 of the file’s 50,088 pages of 16 KB, exactly the pages that hold written bytes, where opening without it brought in 5. The file’s hash and time of change are the same after a writer read it in.
On Linux, GitHub’s runners (EPYC 7763, 9V45 and 9V74, Xeon 8573C, Neoverse-N2) were measured in two runs of four machines each, the first on an earlier commit that makes the same system calls. Their disks read 260 to 660 MB/s in sequence and 5,600 to 11,600 random reads a second one at a time. On the 1,536-dimension vectors read from their own disk, the hundredth result came after 1.3 to 1.7 s with it and 1.5 to 2.9 s without, and hnswlib’s after 1.3 to 2.1 s; in half precision after 0.55 to 1.1 s, from 0.62 to 1.3 s. On a million SIFT vectors of 128 dimensions it came after 1.37 to 1.38 s, from 1.56 to 2.07 s, and in half precision after 0.75 to 0.76 s, from 0.94 to 1.3 s; hnswlib took 1.76 to 2.04 s. With the file already in memory the hundredth result came 8 to 39% sooner.
On one runner whose disk read 263 MB/s in sequence, reading the 713 MB file in took longer than the searches took without it: the hundredth result came after 2.7 s with it and 2.6 s without, and the first 0.56 s later. Elsewhere the first result came up to 0.16 s later with it, while the thread had the disk, and on one machine sooner.
What It Does Not Do
- It does not keep the index in memory. The system may push its pages out again later, as it may any file’s.
- It reads in file order, not the pages searches need first. The upper layers are in the heap, near the end.
- It does nothing on Windows, where
PrefetchVirtualMemorywould be the way to ask. - It does not help an index larger than memory, nor one whose file is already in memory.
What Was Left Out
- Paging in a node’s neighbors together, from inside a search: the prototype’s first result
came in 31 ms instead of 110, but it slowed the searches after it on Linux and every search on a
file already in memory. It is in
IDEAS.mdon theideasbranch. - Locking the index in memory (
mlock): it needs a privilege or a raised limit, and takes memory from everything else for as long as the index is open. - A signal for when the reading is done: nothing measured needs one, and the share of a file in memory is the system’s to report.
Consequences
Positive
- On a file out of memory the hundredth search returns five times as soon on the M5, and sooner than hnswlib’s; on slower Linux disks, up to two fifths sooner.
- A reader’s searches on a file already in memory start up to three times as fast.
- Nothing changes for an index opened without it: no thread is started and no advice given.
Negative
- One more thread for the length of the reading, and a read-only mapping of the file while it lasts.
- On Linux before 5.14 it reads in only some of the index.
- On a disk that reads the file in more slowly than searches bring in what they need, it gains nothing and the first result comes later.
- An application that asks for it on an index much larger than memory pushes everything else out.
ADR-0020: Errors That Say What to Do
Date: 2026-10-10 Status: Proposed
Context
Chassis is used from Rust, C and Python, more and more by coding agents, which read an error
and try again. An error that says what went wrong, with the value, and what to do instead gets
the next attempt right; one that doesn’t sends the reader to search for causes. rustc is the model:
each of its errors shows the values involved and, after help:, a fix.
Seventy common mistakes were run through the Python package before this change. Most raised something, but:
- The C layer passed on only the outermost message, so a missing directory, a path that is a
directory, a denied permission and a reader opening a file that doesn’t exist all read
Failed to open chassis file: <path>. - Messages named another layer’s things: a Python reader was told it came from
chassis_open_reader, and a precision mismatch said “Half” and “Full”, Rust’s names. - A second writer in the same process was told the file was open “by another process”.
- NaN and infinite vectors and queries were taken, and a NaN query returned NaN distances.
- A batch of queries passed to
searchwas reported as a query of three dimensions, a negative dimension count as 4,294,967,293, and options given as a dict as anAttributeError. - Python chose its exception class by searching the message for words such as “dimension”.
Decision
1. Every error has a kind
VectorIndex and IndexReader return chassis_core::Error, whose kind() is one of:
invalid argument, dimension mismatch, options mismatch, not found, not an index, id in use,
locked, read-only, corrupt, full, input or output, other. Inside, the core keeps anyhow; an
error is raised with its kind, and the conversion at the API finds it however much context was
added around it. Error converts into anyhow::Error and Box<dyn Error>, so code that uses
? keeps compiling; code that returned VectorIndex::open(...) as an anyhow::Result without
? needs one.
C has chassis_last_error_code(), one of the CHASSIS_ERROR_ constants, beside the message.
Python’s exception class is chosen by that code, and each class is also the built-in exception it
is a kind of: a dimension mismatch is a ValueError, a missing index a FileNotFoundError.
2. Every message says what was wrong, with the value, and what to do
A message is one line saying what was wrong and with what values, then a line starting
help: with what to do instead. It is in words that fit every language, so the C layer and the
bindings pass it on as it is. The full chain of causes comes with it: the operating system’s
words end a message about a file it refused.
3. Vectors and queries have to be finite
A component that is NaN or infinite gives no distance to anything, and usually comes from a bug upstream, such as normalizing a zero vector. It is refused, with its position, at every metric and precision.
4. Python checks what it can before calling into C
Types and shapes are checked in Python, where the message can use Python’s terms: a 2-D array
where one vector belongs, a float for k, a string id, a function for allowed, an unknown
option (with the name it was likely meant to be, including other libraries’ names for it).
metric and precision take any case and other libraries’ names for them ("l2",
"float16"), and options may be a dict.
5. A reference page for every kind
Errors lists each kind with its causes and fixes, and its name in each
language: Chassis’s rustc --explain.
Consequences
Positive
- Of the seventy mistakes, every one that should fail now raises the exception of its kind, with
the value and a
help:line; the Rust, C and Python tests pin them. - A program can act on the kind instead of matching words in a message.
Negative
- Rust code that returns
VectorIndex::open(...)directly as ananyhow::Result, without?, no longer compiles. - A NaN or infinite vector that was accepted before is now an error. An index holding one from before still opens and searches.
- Messages are longer.
ADR-0021: Distances Computed in Groups
Date: 2026-10-10 Status: Proposed
Context
Out of cache, a search over long vectors waits for memory (ADR-0017). A search knows all the unvisited neighbors of the node it expands before it computes any of their distances, and asks the CPU to start loading their vectors (ADR-0013, ADR-0015), but it then computed the distances one after another: each waited for its own vector, and the CPU had one vector’s loads in flight at a time. On x86, FAISS’s HNSW answered 1.6 to 1.9 times as many queries a second as Chassis at 1,536 dimensions; it computes a node’s neighbors’ distances four at a time.
How deep to prefetch had been tuned per CPU (ADR-0015), and a sweep in half precision found more depth paying on Zen 4 and Zen 5. Whether that should be decided at run time from what the index is, or measured as it runs, was the open question.
Decision
1. A search computes its distances in groups
Of the unvisited neighbors of the node a search expands, the distances are computed W at a time
by one kernel: each piece of the query is loaded once for all W vectors, and each vector keeps
its own sums, so the CPU works on all of them together and waits for their memory at once. What is
left over is computed one at a time. Each group’s distances are taken in as soon as they are
known. Builds link through the same search.
2. The same sums as alone
Each vector in a group keeps the four partial sums a vector alone keeps, added in the same order, so a distance is the same bits whether it was computed in a group or alone, and searches return exactly what they did. One sum per vector was 3 to 10% faster in places, but would have given the same vector distances a rounding apart by different paths, such as a search and an exact scan.
3. The width, decided once from what is known
W is 1 for vectors under a kilobyte, 8 on Zen 5 and later, and 4 everywhere else. It is decided
when a search starts, from the CPU and the vector’s size in bytes, both fixed for an index; no
search measures another. The index’s size against the cache does not enter the rule: in cache a
group cost Zen 3 and Zen 5 up to 8% at one ef in full precision (2 to 6% over all of them), and
gained elsewhere, against a tenth to three fifths out of cache. Timing settings while searches run was left out:
timings are noisy (other processes, clock speed, a file still arriving in memory), and tuning on
the first searches costs the time warm() saves.
4. Prefetch depth stays as it is
Once distances are grouped, deeper prefetch mostly cost speed: the groups already keep several vectors’ memory in flight, which is what more depth was buying.
Measurements
The rule came from two sweeps on the experiment branch (2026-10-10, 31 jobs on GitHub’s runners),
of group widths 1, 2, 4 and 8, with one sum or four per vector, against three prefetch depths, at
128, 960 and 1,536 dimensions, in cache and out, in both precisions. Then the code here was run
against main on the same runners (21 jobs: EPYC 7763 (Zen 3), 9V74 (Zen 4), 9V45 (Zen 5),
Neoverse-N2), queries a second at the same ef, the median over ef 32 to 256, the range over
CPUs of each CPU’s median. Recall was identical at every ef. The 384- and 768-dimension vectors
are the first components of the 1,536-dimension OpenAI ones, which are trained to be embeddings of
their own.
| Vectors | Full precision | Half precision |
|---|---|---|
| 128 × 1,000,000 | 1.00–1.04 | 1.00–1.04 |
| 384 × 99,000 | 1.19–1.55 | 0.99–1.02 (768 bytes: not grouped) |
| 768 × 99,000 | 1.17–1.30 | 1.19–1.58 |
| 960 × 200,000 | 1.11–1.27 | 1.18–1.52 |
| 1,536 × 99,000 | 1.25–1.42 | 1.14–1.60 |
| 1,536 × 2,000, in cache | 0.96–1.15 | 1.03–1.11 |
Zen 5 gained the most in half precision (1.52 to 1.60), Neoverse-N2 in full (1.42 to 1.55); the one loss, 4%, was Zen 3 in cache at 1,536 dimensions. In the sweeps, which compute the same distances, the Xeon 8573C and 6973P-C and Zen 4 gained 1.14 to 1.37 at 1,536 × 99,000 in full precision and 1.31 to 1.35 in half. Batch builds of 384 dimensions and up took 7 to 17% less time, and of 128 between 5% less and 3% more.
On the Apple M5 the comparison was too noisy to settle, with other processes loading the machine: the best of three runs was 1.1 to 1.7 times as fast with groups, and the ranges overlapped.
Deeper prefetch, with groups of four or eight: asking for the whole vector into L2 or L1 was slower than today’s depth in 226 of the first sweep’s 240 settings with long vectors (machine, size, precision, width), by up to a quarter, and more than 2% faster in 10.
Consequences
Positive
- Searches over vectors of a kilobyte or more are a tenth to three fifths faster out of cache, and builds of them take 7 to 17% less time.
- Results are unchanged, bit for bit.
Negative
- The search loop is compiled once per width: three times as much of it in the library.
- The rule is from the CPUs measured. A CPU not among them gets four.
Building from Source
Requirements
- Rust 1.88 or later
- A C compiler (for some dependencies)
- Git
Clone the Repository
git clone https://github.com/tanvincible/chassis.git
cd chassis
Build
Build the project in release mode:
cargo build --release
The compiled library will be in target/release/.
For development builds without optimizations:
cargo build
Check for Issues
Run the linter and formatter:
cargo clippy
cargo fmt --check
Fix any warnings before submitting a pull request.
Platform-Specific Notes
Linux
No special setup required. The build uses standard glibc.
macOS
Xcode command line tools are required for the C compiler:
xcode-select --install
Windows
Install Visual Studio Build Tools or the full Visual Studio IDE. The Rust installer will detect and use the MSVC toolchain automatically.
Cross-Compilation
Chassis is designed to work on both x86 and ARM. To cross-compile for ARM on an x86 host:
rustup target add aarch64-unknown-linux-gnu
cargo build --target aarch64-unknown-linux-gnu --release
Test on actual ARM hardware, as emulation may not catch alignment issues.
Testing Guide
Chassis has a comprehensive test suite covering unit tests, integration tests, and compile-time borrow checker validation.
Running Tests
All Tests
# Run all tests in the workspace
cargo test --workspace
# Run with output
cargo test --workspace -- --nocapture
# Run with specific number of threads
cargo test --workspace -- --test-threads=4
Specific Test Suites
# Storage layer tests
cargo test --package chassis-core --test storage_tests
# Graph I/O tests
cargo test --package chassis-core --test graph_io_tests
# Linking tests
cargo test --package chassis-core --test link_tests
# Search tests
cargo test --package chassis-core --test search_tests
# Compile-time safety tests
cargo test --package chassis-core --test compile_fail
Unit Tests (Embedded)
# Run unit tests in a specific module
cargo test --package chassis-core storage::tests
cargo test --package chassis-core distance::tests
cargo test --package chassis-core hnsw::node::tests
Test Coverage
Storage Layer Tests (storage_tests.rs)
Tests the memory-mapped storage engine:
Basic Operations:
test_create_new_storage(): File creation and initializationtest_insert_and_retrieve(): Vector insertion and retrievaltest_multiple_inserts(): Sequential insertiontest_insert_wrong_dimensions(): Dimension validation
Zero-Copy Access:
test_get_vector_slice(): Slice-based accesstest_vector_slice_lifetime(): Borrow checker enforcement
Durability:
test_commit(): Persistence guaranteestest_persistence(): File reopen after committest_uncommitted_changes(): Data loss on crash simulation
File Growth:
test_storage_growth(): Automatic file expansiongrowthbench (storage_bench): growing an empty file to 1,000 vectors
Concurrency:
test_exclusive_lock(): File lockingtests/reader_tests.rs: readers in this and another process while a writer adds, deletes and restarts
Graph I/O Tests (graph_io_tests.rs)
Tests HNSW graph persistence and addressing:
Graph Header:
test_graph_header_roundtrip(): Serialization/deserializationtest_graph_header_validation(): Magic bytes and version checkstest_graph_header_persistence(): Survives reopening
Node Records:
test_node_record_write_read(): Basic record I/Otest_sequential_node_writes(): Batch writestest_node_record_updates(): Updating existing nodes
Addressing:
test_o1_addressing(): Offset computation correctnesstest_node_offset_consistency(): Deterministic offsetstest_addressing_formula_correctness(): Mathematical validation
Neighbor Iteration:
test_neighbors_iter_from_mmap(): Zero-allocation iterationtest_neighbors_iter_empty_layer(): Edge case handlingtest_neighbors_iter_filters_invalid():INVALID_NODE_IDfiltering
Linking Tests (link_tests.rs)
Tests bidirectional graph linking and diversity heuristics:
Basic Linking:
test_link_single_node(): Single node insertiontest_link_multiple_nodes(): Sequential linkingtest_forward_links_exist(): Outgoing edges persisted
Backward Links:
test_backward_links_added(): Reciprocal edges createdtest_idempotency(): Duplicate link preventiontest_backward_link_pruning(): Overflow handling
Diversity Heuristic:
test_diversity_selection(): Heuristic 2 applicationtest_starvation_fallback(): Minimum degree guarantee
Invariant Enforcement:
test_self_links_filtered(): No self-loopstest_future_neighbors_filtered(): No forward references (Model A)test_sequential_id_enforcement(): Node ID monotonicity
Crash Consistency:
test_crash_during_linking(): Partial updates are safetest_ghost_nodes(): Unreachable nodes ignored
Search Tests (search_tests.rs)
Tests HNSW search algorithm:
Basic Search:
test_search_empty_graph(): Empty graph returns empty resultstest_search_single_node(): Trivial casetest_search_k_neighbors(): Correct k returnedtest_search_ef_correction():ef < khandled
Result Quality:
test_search_accuracy(): Distance sortingtest_search_recall(): Finds true nearest neighborstest_search_no_duplicates(): Unique results only
Visited Filter:
test_visited_filter(): Dense array correctnesstest_visited_filter_out_of_bounds(): Bounds checking
Edge Cases:
test_nan_safe_ordering(): NaN distances don’t panictest_search_large_ef(): High ef valuestest_search_disconnected_graph(): Unreachable nodes
Performance:
test_search_scales_logarithmically(): Hop count grows slowly
Compile-Time Safety Tests (compile_fail.rs)
Uses trybuild to verify borrow checker enforcement:
UI Tests (tests/ui/):
-
slice_then_insert.rs(Expected: Fails to compile)#![allow(unused)] fn main() { let slice = storage.get_vector_slice(0)?; storage.insert(&vec![1.0; 128])?; // Mutable borrow while slice alive println!("{}", slice[0]); }Error: Cannot borrow
storageas mutable -
slice_then_commit.rs(Expected: Fails to compile)#![allow(unused)] fn main() { let slice = storage.get_vector_slice(0)?; storage.commit()?; // Mutable borrow (commit modifies mmap) println!("{}", slice[0]); }Error: Cannot borrow
storageas mutable -
slice_escapes_scope.rs(Expected: Fails to compile)#![allow(unused)] fn main() { let slice = { let storage = Storage::open(...)?; storage.get_vector_slice(0)? }; // storage dropped, slice now dangling println!("{}", slice[0]); }Error: Slice lifetime exceeds storage
-
return_slice_then_mutate.rs(Expected: Fails to compile)#![allow(unused)] fn main() { fn get_slice(storage: &Storage) -> &[f32] { storage.get_vector_slice(0).unwrap() } let slice = get_slice(&storage); storage.insert(&vec![1.0; 128])?; // Mutable borrow }Error: Cannot borrow
storageas mutable
Run UI Tests:
cargo test --package chassis-core --test compile_fail
Expected output: All tests pass (compilation failures are expected).
Unit Tests by Module
distance.rs Tests
#![allow(unused)]
fn main() {
#[test]
fn test_euclidean_distance_basic()
fn test_cosine_distance()
fn test_simd_correctness_small() // Tests tail loop
fn test_simd_correctness_large() // Tests main loop
fn test_simd_random_vectors() // Fuzz testing
fn test_simd_edge_cases() // Zero, identical, sparse
fn test_avx2_specific() // x86_64 only
fn test_neon_specific() // aarch64 only
}
Coverage:
- SIMD vs scalar equivalence
- Edge cases (zeros, identical vectors, NaN)
- Dimension scaling (3D to 3072D)
- Architecture-specific paths
header.rs Tests
#![allow(unused)]
fn main() {
#[test]
fn test_roundtrip()
fn test_any_flipped_bit_invalidates_the_copy()
fn test_newer_versions_are_errors_and_old_ones_are_not_v3()
fn test_newest_copy()
}
Coverage:
- Header validation logic
- Magic byte checking
- Dimension bounds (0, MAX_DIMENSIONS+1)
hnsw/node.rs Tests
#![allow(unused)]
fn main() {
#[test]
fn test_node_header_size()
fn test_record_size_calculation()
fn test_layer_offset_calculation()
fn test_set_and_get_neighbors()
fn test_add_neighbor()
fn test_serialization_roundtrip()
fn test_node_to_record_conversion()
fn test_compute_node_offset()
fn test_addressing_formula_consistency()
}
Coverage:
- Fixed-size record layout
- Neighbor management
- Serialization correctness
- O(1) addressing formula
hnsw/graph.rs Tests
#![allow(unused)]
fn main() {
#[test]
fn test_graph_header_roundtrip()
fn test_o1_addressing_no_hashmap()
fn test_graph_persistence()
fn test_insert_out_of_order_panics() // Debug only
fn test_insert_duplicate_panics() // Debug only
fn test_update_existing_node()
}
Coverage:
- Graph header persistence
- O(1) node addressing
- Sequential ID enforcement
- Update vs insert semantics
Testing Best Practices
1. Use Temporary Files
#![allow(unused)]
fn main() {
use tempfile::NamedTempFile;
#[test]
fn my_test() -> anyhow::Result<()> {
let temp_file = NamedTempFile::new()?;
let storage = Storage::open(temp_file.path(), 128)?;
// Test logic...
Ok(())
} // File automatically deleted
}
Why: Prevents test pollution and ensures isolation.
2. Test Invariants Explicitly
#![allow(unused)]
fn main() {
#[test]
fn test_node_id_invariant() {
let graph = create_test_graph();
// Invariant: node_count tracks reachable nodes
assert_eq!(graph.node_count(), 10);
// Invariant: all nodes have sequential IDs
for i in 0..graph.node_count() {
assert!(graph.read_node_record(i).is_ok());
}
}
}
3. Test Edge Cases
#![allow(unused)]
fn main() {
#[test]
fn test_empty_graph() {
let graph = create_empty_graph();
assert!(graph.search(&query, 10, 50)?.is_empty());
}
#[test]
fn test_single_node() {
let graph = create_graph_with_one_node();
let results = graph.search(&query, 10, 50)?;
assert_eq!(results.len(), 1);
}
}
4. Use Deterministic Random Data
#![allow(unused)]
fn main() {
#[test]
fn test_with_deterministic_random() {
use std::collections::hash_map::RandomState;
use std::hash::{BuildHasher, Hash, Hasher};
fn hash_to_f32(seed: u64) -> f32 {
let state = RandomState::new();
let mut hasher = state.build_hasher();
seed.hash(&mut hasher);
(hasher.finish() % 10000) as f32 / 10000.0
}
let vector: Vec<f32> = (0..128).map(hash_to_f32).collect();
// Reproducible across test runs
}
}
5. Test Concurrency (Where Applicable)
#![allow(unused)]
fn main() {
#[test]
fn test_exclusive_lock() {
let temp_file = NamedTempFile::new().unwrap();
let path = temp_file.path();
let _storage1 = Storage::open(path, 128).unwrap();
// Second open should fail
let result = Storage::open(path, 128);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("already open"));
}
}
Running Tests with Coverage
Install tarpaulin:
cargo install cargo-tarpaulin
Generate coverage report:
cargo tarpaulin --workspace --out Html --output-dir coverage
Open coverage/index.html in browser.
Target: Aim for >80% line coverage, >90% for critical paths (storage, search).
Continuous Integration
Example GitHub Actions workflow:
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: dtolnay/rust-toolchain@stable
- name: Run tests
run: cargo test --workspace --verbose
- name: Run compile-fail tests
run: cargo test --package chassis-core --test compile_fail
- name: Check formatting
run: cargo fmt -- --check
- name: Run clippy
run: cargo clippy --workspace -- -D warnings
Benchmarking Guide
Chassis uses Criterion.rs for statistical benchmarking with outlier detection and HTML reporting.
The example outputs below come from an older machine. Their fsync timings are too fast for a real
disk (the sync probably went to tmpfs or a virtual disk), and their search timings come from
hand-wired graphs, not ones built by VectorIndex. Current numbers are in
Performance.
Running Benchmarks
All Benchmarks
# Run all benchmarks
cargo bench --workspace
# Takes ~15-20 minutes for full suite
Specific Benchmarks
# Storage layer
cargo bench --bench storage_bench
# Distance computation
cargo bench --bench distance_bench
# HNSW search
cargo bench --bench search_bench
# Graph linking
cargo bench --bench link_bench
# Graph I/O
cargo bench --bench graph_io_bench
# Node operations
cargo bench --bench hnsw_node_bench
Filtering Benchmarks
# Run only insert benchmarks
cargo bench --bench storage_bench -- insert
# Run only SIMD benchmarks
cargo bench --bench distance_bench -- simd
# Run only high_ef search tests
cargo bench --bench search_bench -- high_ef
Benchmark Suites
1. Storage Benchmarks (storage_bench.rs)
Raw Insert Performance
#![allow(unused)]
fn main() {
raw_insert/single_insert_no_commit
}
Measures pure insertion speed without fsync.
Result: ~7.4 µs per vector (134K vectors/sec)
What it tests: Memory-mapped write performance, file growth overhead.
Durable Insert Performance
#![allow(unused)]
fn main() {
durable_insert/insert_with_commit
}
Measures insertion + fsync (full durability guarantee).
Result: ~6.0 µs per operation (166K vectors/sec)
What it tests: Kernel flush + disk sync latency.
Batch Insert Performance
#![allow(unused)]
fn main() {
batch_insert/10
batch_insert/100
batch_insert/1000
}
Measures batched insertions with single commit.
Results:
- 10 vectors: 174K/sec
- 100 vectors: 173K/sec
- 1000 vectors: 171K/sec
What it tests: Fsync amortization, optimal batch size.
Read Performance
#![allow(unused)]
fn main() {
hot_read/get_vector_cached // L1/L2 cache hit
cold_read/get_vector_cold_start // Page fault
sequential_read/read_1000_sequential
}
Results:
- Hot: 72 ns (13.8M vectors/sec)
- Cold: 15 µs (66K vectors/sec)
- Sequential 1000: 125 µs total (8M vectors/sec)
What it tests: Cache locality, prefetcher effectiveness, kernel page-in latency.
Growth
#![allow(unused)]
fn main() {
growth/grow_from_empty_to_1000
}
Measures growing a file from empty to 1,000 vectors and committing it.
Result: ~5.9 ms total for 1000 vectors, measured with file format 2, which remapped the file to grow it. Format 3 appends segments instead; not re-measured.
What it tests: truncate() + mmap() overhead.
2. Distance Benchmarks (distance_bench.rs)
Distance by Dimension
#![allow(unused)]
fn main() {
distance_by_dimension/simd/{64,128,384,768,1536,3072}
distance_by_dimension/scalar/{64,128,384,768,1536,3072}
}
Compares SIMD vs scalar across dimensions.
Key Results (AVX2):
- 128D: 9.2 ns SIMD, 53.8 ns scalar → 5.8x speedup
- 768D: 40 ns SIMD, 370 ns scalar → 9.2x speedup
- 1536D: 70 ns SIMD, 750 ns scalar → 10.7x speedup
What it tests: 4-way accumulator effectiveness, main loop vs tail loop ratio.
Batch Distances
#![allow(unused)]
fn main() {
batch_distances/simd_batch
batch_distances/scalar_batch
}
100 vectors × 768D per iteration.
Results:
- SIMD: 4.97 µs → 15.5 Gelem/s
- Scalar: 38.0 µs → 2.02 Gelem/s
What it tests: Real-world search pattern (query vs many candidates).
Unaligned Access
#![allow(unused)]
fn main() {
unaligned_access/simd_unaligned
}
Tests SIMD performance with misaligned memory.
Result: ~58 ns (minimal penalty)
What it tests: _mm256_loadu_ps vs _mm256_load_ps.
Small Vectors
#![allow(unused)]
fn main() {
small_vectors/dims/{3,7,15,31,63}
}
Tests scalar tail loop dominance.
Results:
- 3D: 4.67 ns
- 63D: 8.26 ns
What it tests: Scalar tail performance, setup overhead.
Sparse Vectors
#![allow(unused)]
fn main() {
sparse_vectors/simd_sparse_10pct
sparse_vectors/scalar_sparse_10pct
}
90% zeros, 10% non-zero.
What it tests: SIMD efficiency with sparse data (still faster despite wasted computation).
Memory Bandwidth
#![allow(unused)]
fn main() {
memory_bandwidth/simd/{4096,8192,16384}
}
Very large vectors to saturate DRAM bandwidth.
Results:
- 4096 floats: 505 GiB/s
- 8192 floats: 409 GiB/s
- 16384 floats: 394 GiB/s
What it tests: L3 cache miss → DRAM throughput limit.
3. Search Benchmarks (search_bench.rs)
Search Baseline
#![allow(unused)]
fn main() {
search_baseline/ef/{8,16,32,64,128}
}
Fixed k=10, varying ef (search quality).
Results:
- ef=8: 2.81 µs (356K queries/sec)
- ef=32: 5.85 µs (171K queries/sec)
- ef=128: 20.1 µs (50K queries/sec)
What it tests: ef scaling (logarithmic candidate pool growth).
High ef Stress Test
#![allow(unused)]
fn main() {
high_ef_stress/ef/{64,128,256,512}
}
5000-node graph, high ef values.
Results:
- ef=256: 45.3 µs
- ef=512: 101.6 µs
What it tests: Dense visited filter performance, heap operations.
Varying k
#![allow(unused)]
fn main() {
varying_k/k/{1,5,10,50,100}
}
Different result set sizes.
Results:
- k=1: 8.63 µs
- k=10: 8.98 µs
- k=100: 30.7 µs (ef=200)
What it tests: Result heap size impact (minimal for k < ef).
Graph Size Scaling
#![allow(unused)]
fn main() {
graph_size_scaling/nodes/{100,500,1000,5000}
}
Fixed ef=50, k=10.
Results:
- 100 nodes: 6.32 µs
- 5000 nodes: 9.07 µs
What it tests: Sub-linear scaling (HNSW’s logarithmic hop count).
Dimension Scaling
#![allow(unused)]
fn main() {
dimension_scaling/dimensions/{64,128,384,768,1536}
}
Results:
- 64D: 7.38 µs
- 768D: 17.5 µs
- 1536D: 35.2 µs
What it tests: Distance computation dominates at high dimensions.
4. Linking Benchmarks (link_bench.rs)
Single Node Linking
#![allow(unused)]
fn main() {
link_single_node/neighbors/{4,8,16,32}
}
Link one node with varying neighbor counts.
Results:
- 4 neighbors: 39.5 µs
- 16 neighbors: 37.4 µs
- 32 neighbors: 68.0 µs (pruning kicks in)
What it tests: Neighbor slot filling, pruning threshold.
Pruning Pressure
#![allow(unused)]
fn main() {
link_with_pruning/full_list_pruning
}
Add node to hub with full neighbor list (triggers diversity heuristic).
Result: ~20.3 µs
What it tests: The cost of diversity pruning when a hub’s list is full.
Multi-Layer Linking
#![allow(unused)]
fn main() {
multilayer_linking/layers/{1,2,4,8}
}
Results:
- 1 layer: 22.1 µs
- 4 layers: 25.2 µs
- 8 layers: 29.1 µs
What it tests: Layer traversal overhead (~1.5 µs per additional layer).
Batch Construction
#![allow(unused)]
fn main() {
batch_linking/nodes/{10,50,100}
}
Sequential graph construction.
Results:
- 10 nodes: 37.5 µs total → 3.75 µs/node
- 100 nodes: 331 µs total → 3.31 µs/node
What it tests: Batching efficiency, graph growth patterns.
Worst Case: Identical Vectors
#![allow(unused)]
fn main() {
worst_case_clustering/identical_vectors
}
32 identical vectors (diversity heuristic degenerates).
Result: 394 µs total
What it tests: Cache still helps even when all distances equal.
5. Graph I/O Benchmarks (graph_io_bench.rs)
Graph Header
#![allow(unused)]
fn main() {
graph_header/read // 8.29 ns
graph_header/write // 2.00 ns
}
What it tests: 64-byte aligned header access (cache-resident).
Node Record I/O
#![allow(unused)]
fn main() {
node_record/read // 320 ns
node_record/read_sequential // 279 ns per record
node_record/read_random // 250 ns per record
node_record/write // 283 ns
}
What it tests: Record deserialization, validation overhead, sequential vs random access.
Neighbor Iteration
#![allow(unused)]
fn main() {
neighbors_mmap/iter_layer0 // 43.3 ns (zero-alloc)
neighbors_mmap/iter_via_record_layer0 // 330 ns (with record read)
}
What it tests: Zero-allocation iteration vs deserialization overhead.
Speedup: 7.6x for direct mmap iteration.
Node Offset Computation
#![allow(unused)]
fn main() {
offset/compute_single // 0.83 ns
offset/compute_batch_1000 // 398 ns total → 0.4 ns each
}
What it tests: O(1) addressing formula, instruction pipelining.
Search Pattern Simulation
#![allow(unused)]
fn main() {
search_pattern/visit_100_nodes // 33.7 µs (with record read)
search_pattern/visit_100_nodes_mmap // 2.96 µs (zero-alloc)
}
What it tests: Hot path allocation elimination.
Speedup: 11.4x with zero-copy iteration.
6. Node Benchmarks (hnsw_node_bench.rs)
Record Size Calculation
#![allow(unused)]
fn main() {
record_size/calculate/m{16,32,64}_l{8,16}
}
Result: ~320-340 ps (sub-nanosecond)
What it tests: Constant-time calculation (no runtime overhead).
Serialization
#![allow(unused)]
fn main() {
record_serialization/to_bytes // 126 ns
record_serialization/from_bytes // 194 ns
}
What it tests: Memory copy + validation cost.
Neighbor Access
#![allow(unused)]
fn main() {
neighbor_access/get_neighbors_layer0 // 188 ns (allocates Vec)
neighbor_access/neighbors_iter_layer0 // 10.7 ns (zero-alloc)
}
Speedup: 17.6x with iterator.
Node Conversion
#![allow(unused)]
fn main() {
node_conversion/node_to_record // 73.6 ns
node_conversion/record_to_node // 400 ns
}
What it tests: In-memory representation vs on-disk format.
Interpreting Results
Criterion Output
distance_by_dimension/simd/768
time: [39.783 ns 40.207 ns 40.631 ns]
thrpt: [18.902 Gelem/s 19.101 Gelem/s 19.305 Gelem/s]
Found 114 outliers among 1000 measurements (11.40%)
30 (3.00%) high mild
84 (8.40%) high severe
Reading:
- time: [lower bound, estimate, upper bound] at 95% confidence
- thrpt: Throughput (elements/second)
- outliers: Measurements discarded (system noise)
Comparing Runs
# Baseline
cargo bench --bench distance_bench -- simd/768 > baseline.txt
# After optimization
cargo bench --bench distance_bench -- simd/768 > optimized.txt
# Compare
critcmp baseline.txt optimized.txt
Example output:
group baseline optimized
----- -------- ---------
simd/768 40.2 ns (1.00x) 35.1 ns (1.15x faster)
Best Practices
1. Warm Up System
# Disable CPU frequency scaling
sudo cpupower frequency-set --governor performance
# Drop caches (optional)
sudo sh -c 'echo 3 > /proc/sys/vm/drop_caches'
2. Reduce System Noise
# Close unnecessary apps
# Disable background services
# Run benchmarks when system is idle
3. Use Consistent Hardware
- Same machine for before/after comparisons
- Note CPU model, RAM, storage type in results
- Disable turbo boost for reproducibility (optional)
4. Increase Sample Size for Stability
#![allow(unused)]
fn main() {
group.sample_size(1000); // Default: 100
group.measurement_time(Duration::from_secs(10)); // Default: 5s
}
5. Profile Hot Paths
# Install perf
sudo apt install linux-tools-generic
# Run with perf
cargo bench --bench search_bench --no-run
perf record --call-graph=dwarf target/release/deps/search_bench-*
perf report
Regression Testing
Create a CI job to detect performance regressions:
- name: Benchmark
run: cargo bench --workspace -- --save-baseline main
- name: Compare
run: |
cargo bench --workspace -- --baseline main
critcmp main current --threshold 5 # Fail if >5% slower
Generating HTML Reports
# Run benchmarks
cargo bench --workspace
# Open reports
open target/criterion/report/index.html
Report includes:
- Performance graphs over time
- Distribution histograms
- Throughput charts
- Outlier analysis