For the release process (versioning, upgrade policy, cutting a release PR), see release.md.
For the common build-and-install flow there is a thin Makefile wrapper (release build against the installed PostgreSQL by default):
make # build the extension
sudo make install # configure + build + install
make install PG_CONFIG=/path/to/pg_config # pick a PostgreSQLSee the Makefile header for the other variables (BUILDTYPE, BUILDDIR, MESON_ARGS). Everything below uses meson directly.
# Setup build directory
meson setup builddir
# Compile
meson compile -C builddir
# Run tests
meson test -C builddir
# Format code
meson compile -C builddir format| Option | Values | Default | Description |
|---|---|---|---|
postgresql |
auto, enabled, disabled |
auto |
Build PostgreSQL extension |
pg_config |
path | (auto-detect) | Path to pg_config |
simd |
full, compiler, none |
full |
SIMD implementation mode |
native |
true, false |
false |
Use -march=native for local builds |
blas |
auto, enabled, disabled |
auto |
CBLAS for matrix operations |
tools |
auto, enabled, disabled |
auto |
Developer tools (CLI, standalone library, unit tests) |
Use -Dpostgresql=enabled to require the extension build (fails if PostgreSQL
is not found). Use -Dpostgresql=disabled to build only the standalone library
and CLI tools.
The tools default (auto) builds the developer tools in a git checkout but
skips them when building from a release tarball, so packagers get an
extension-only build unless they pass -Dtools=enabled. PostgreSQL regression
tests are unaffected.
simd and native are covered in simd.md.
A CPU-optimized BLAS library speeds up RaBitQ encoding by about 4x for
batch operations. meson setup looks for CBLAS when -Dblas=auto (the
default) and uses it if found. -Dblas=enabled fails setup if none is
found; -Dblas=disabled skips detection.
| CPU | Library | Link |
|---|---|---|
| Intel | Intel oneMKL | intel.com/oneapi/onemkl |
| AMD | AOCL-BLIS | amd.com/aocl/blis |
| ARM | ARM Performance Libraries | developer.arm.com |
| Any | OpenBLAS | openblas.net |
# Arch Linux (AUR packages require yay or similar)
pacman -S blas-openblas # Generic (official repo)
yay -S blas-mkl # Intel (AUR)
yay -S blas-aocl-gcc # AMD (AUR)
# Debian/Ubuntu
apt install libopenblas-dev
# Fedora
dnf install openblas-develMeson has built-in coverage support that auto-detects the compiler and tools.
One of:
- GCC: gcovr or lcov/genhtml
- Clang: llvm-cov
Install gcovr (recommended):
pip install gcovr# Setup with coverage enabled
meson setup builddir-cov -Db_coverage=true
# Build and run tests
meson compile -C builddir-cov
meson test -C builddir-cov
# Generate HTML report
ninja -C builddir-cov coverage-htmlThe HTML report is generated in builddir-cov/meson-logs/coveragereport/.
Meson provides several coverage targets:
| Target | Description |
|---|---|
coverage |
Generate coverage report (text) |
coverage-html |
Generate HTML report |
coverage-xml |
Generate XML report (Cobertura) |
coverage-text |
Generate text summary |
Meson supports Clang/GCC sanitizers via the b_sanitize option.
Detects memory errors: use-after-free, buffer overflow, double-free, memory leaks.
meson setup builddir-asan -Db_sanitize=address
meson test -C builddir-asanDetects undefined behavior: null pointer dereference, signed integer overflow, etc.
meson setup builddir-ubsan -Db_sanitize=undefined
meson test -C builddir-ubsanMultiple sanitizers can be combined:
meson setup builddir-san -Db_sanitize=address,undefined
meson test -C builddir-san| Sanitizer | Option | Detects |
|---|---|---|
| AddressSanitizer | address |
Use-after-free, buffer overflow, leaks |
| LeakSanitizer | leak |
Memory leaks (included in ASan) |
| UndefinedBehaviorSanitizer | undefined |
Undefined behavior |
| ThreadSanitizer | thread |
Data races |
| MemorySanitizer | memory |
Uninitialized reads (Clang only) |
Note: ThreadSanitizer and MemorySanitizer cannot be combined with AddressSanitizer.
Meson generates compile_commands.json which clang-tidy uses for analysis.
# Run clang-tidy via meson target
meson compile -C builddir tidy
# Or run directly on all source files
clang-tidy -p builddir src/*.c
# Run with fixes applied
clang-tidy -p builddir --fix src/*.cWrap the build command with scan-build:
# Basic scan
scan-build meson compile -C builddir
# Generate HTML report in reports/
scan-build -o reports meson compile -C builddir
# View report
scan-view reports/<timestamp>/Note: scan-build requires a clean build to analyze all files:
meson compile -C builddir --clean
scan-build meson compile -C builddir# Setup debug build
meson setup builddir-debug --buildtype=debug
# Setup release build
meson setup builddir-release --buildtype=releaseThe core engine builds outside PostgreSQL, which is what the unit tests and
the micro-benchmarks use. The vectorsearch CLI benchmarks distance
computation:
# default: dim=768, count=10000
./bin/vectorsearch bench distance
./bin/vectorsearch bench distance --dim 1536 --count 50000
# machine-readable results (file, or omit / '-' for stdout).
# JSON on stdout sends the human text to stderr; --quiet drops it.
./bin/vectorsearch bench distance --json /tmp/distance.json
./bin/vectorsearch bench distance --json | jq .
./bin/vectorsearch bench distance --json --quiet
# Every bench starts with an adaptive CPU spin-up: a SIMD busy loop
# that exits once chunk throughput plateaus, so timed runs start at a
# settled clock. --spinup caps it in ms (0 disables); the JSON root
# records what it saw under "spinup".
./bin/vectorsearch bench --spinup 500 distance
# One timed sample spans at least --min-runtime ms (default 25),
# repeating the measured pass, so a sample outlasts a transient
# disturbance that a single pass would soak up whole. 0 times single
# passes.
./bin/vectorsearch bench --min-runtime 100 distance
# Diff two result files. Named metrics pair by name; unit decides
# whether higher or lower is better. A drop inside 2·SE of the two
# means (from stddev and n) is noise: ignored, or softened to a
# warning when it is fail-grade. A count metric is reported without
# gating. Exit 1 if any metric fails.
./bin/vectorsearch bench compare base.json head.jsonbench topk times the top-K a posting scan fills per query: the
deduplicating insert, the extract, and the unique insert that centroid
search uses, at several k:
# default: k=10,100,1000,5000, 100 candidates per k
./bin/vectorsearch bench topkComparing SIMD implementations is covered in simd.md.
bench idset times the id set the candidate extract deduplicates with,
and reports the longest run of occupied slots, the most slots a lookup
walks:
# default: 1000, 10000 and 100000 posting-encoded TIDs
./bin/vectorsearch bench idsetThe scripts/profile.sh script automates profiling with perf and generates
interactive flame graphs.
perf(linux-tools-common, linux-tools-generic)- FlameGraph (auto-downloaded to
/tmp/FlameGraphif not found)
Install perf:
sudo apt-get install linux-tools-common linux-tools-generic# Profile any command
./scripts/profile.sh ./bin/vectorsearch bench distance --dim 768 --count 10000
# Convenience script for benchmarks
./scripts/profile-bench.sh 768 10000 avx512
# Profile with custom events
./scripts/profile.sh --events cache-misses ./bin/vectorsearch bench distance
# Keep perf.data for manual analysis
./scripts/profile.sh --keep-perf-data ./bin/vectorsearch bench distanceThe script generates files in the profiles/ directory:
profiles/flamegraph.svg- Interactive flame graph (CPU time by function)profiles/flamegraph-icicle.svg- Inverted flame graph (call chains from bottom)profiles/flamegraph.folded- Collapsed stack traces (for manual analysis)profiles/perf.data- Raw perf data (if--keep-perf-dataused)
Open in browser:
firefox profiles/flamegraph.svgThe script automatically creates builddir-profile with:
- Release optimizations (
-O3) - Frame pointers (
-fno-omit-frame-pointer) - Debug symbols (
-g)
This ensures accurate stack traces while maintaining realistic performance.
# Profile cache misses instead of CPU cycles
./scripts/profile.sh --events cache-misses --output cache-profile \
./bin/vectorsearch bench distance --dim 768 --count 100000
# Profile branch mispredictions
./scripts/profile.sh --events branch-misses --output branch-profile \
./bin/vectorsearch bench distance --dim 384 --count 50000
# Profile at higher frequency (more samples, more overhead)
./scripts/profile.sh --freq 4999 ./bin/vectorsearch bench distance
# Profile multiple events
./scripts/profile.sh --events cycles,cache-misses \
./bin/vectorsearch bench distance- Width: Total time spent in function (including children)
- Color: Random (for visual distinction, not meaningful)
- Interactive: Click to zoom, search for function names
- Icicle graph: Shows call chains from root (main) at bottom
Look for:
- Wide bars = hot functions (optimization targets)
- Tall stacks = deep call chains (potential inlining opportunities)
- Unexpected functions = hidden overhead
For detailed analysis, use perf report directly:
# Generate profile with --keep-perf-data
./scripts/profile.sh --keep-perf-data ./bin/vectorsearch bench distance
# Interactive report
perf report
# Text report
perf report --stdio
# Show annotated source code
perf annotate vs_distance_l2_avx512Code is formatted with clang-format. The project prefers clang-format-18 for consistency but falls back to any available version.
# Format all source files
meson compile -C builddir formatStandalone scripts in scripts/ci/ can be run locally for testing and
debugging. These are the same scripts used by GitHub Actions.
| Script | Description |
|---|---|
build.sh |
Build and run tests |
coverage.sh |
Build with coverage, generate report |
sanitizers.sh |
Build and test with sanitizers |
lint.sh |
Check formatting and run clang-tidy |
check-commit-messages.sh |
Reject commit messages that credit an AI agent |
check-commit-count.sh |
Require one commit per pull request unless its description disables the check |
pgspot.sh |
Static security lint of the install-time SQL (pgspot) |
# Basic build and test
./scripts/ci/build.sh
# Build with coverage
./scripts/ci/coverage.sh
# Run with AddressSanitizer
./scripts/ci/sanitizers.sh address
# Run with UndefinedBehaviorSanitizer
./scripts/ci/sanitizers.sh undefined
# Run with both (default)
./scripts/ci/sanitizers.sh address,undefined
# Check formatting and run clang-tidy
./scripts/ci/lint.sh
# Check the branch's commit messages for AI agent attribution
./scripts/ci/check-commit-messages.sh # origin/main..HEAD
./scripts/ci/check-commit-messages.sh .git/COMMIT_EDITMSG
# Check that a pull request has one commit (or disables the check)
./scripts/ci/check-commit-count.sh # origin/main..HEAD
BASE_SHA=main HEAD_SHA=my-branch ./scripts/ci/check-commit-count.sh
# Security-lint the install-time SQL (needs: pip install pgspot)
./scripts/ci/pgspot.shAll scripts accept an optional build directory argument:
./scripts/ci/build.sh my-builddir
./scripts/ci/coverage.sh my-coverage-dirThe project uses GitHub Actions for CI with the following workflows:
| Workflow | Trigger | Description |
|---|---|---|
build.yml |
Push, PR | Build and test |
sanitizers.yml |
Push, PR | ASan and UBSan tests |
coverage.yml |
Push, PR | Coverage report, upload to Codecov |
lint.yml |
Push, PR | Format check and clang-tidy |
pgspot.yml |
Push, PR | pgspot security lint of install-time SQL |
codeql.yml |
Push, PR, Weekly, push to trigger/codeql, manual |
GitHub CodeQL static analysis of C, workflows and Python |
coverity.yml |
Weekly, push to trigger/coverity, manual |
Coverity Scan static analysis; Scan caps submissions, so it never runs on PRs |
Coverity reads an analysis model from
the project's settings rather than from the uploaded
build, so test/unit/coverity-model.c takes effect only once a project
admin uploads it there, and a change to it needs a re-upload. The model
describes what the analysis cannot work out for itself -- currently that
a failed assertion ends the test body it is in.
PRISM uses Vec32TypeOps vtables for type-generic algorithms (k-means,
RaBitQ) that work over both float32 and float16 input. Naive vtable dispatch
through function pointers prevents inlining and auto-vectorization in hot loops,
causing 10-15% overhead on inner loops like dot product.
We use a pattern inspired by WebKit's libpas allocator to get zero-cost polymorphism in C. The key insight: dispatch once at the outer level, then use direct inline functions in the hot loop.
Three dispatch modes, from fastest to most flexible:
| Mode | How | Use When |
|---|---|---|
| Inlined | Convert input to known type, call inline function directly | Hot inner loops (distance, dot product) |
| Specialized | Call through vtable once per batch/iteration | Warm paths (norm precompute, centroid update) |
| Virtual | Call through vtable per element | Cold paths, or when conversion is too expensive |
The assignment inner loop computes dot products between every vector and every centroid. This is the hottest code in k-means.
Bad — virtual dispatch per dot product (prevents vectorization):
for (uint32_t i = 0; i < nvecs; i++) {
const void *v = vec_at(st, i);
for (uint32_t j = 0; j < nlist; j++) {
/* Function pointer call — not inlineable */
float dp = st->ops->dot_product(v, centroid[j], dim);
...
}
}Good — convert once, inline the hot loop:
/* Warm path: one vtable call per vector to get float32 view */
const float *vf = st->ops->to_float_block(block, buf, count, dim);
for (uint32_t i = 0; i < count; i++) {
for (uint32_t j = 0; j < nlist; j++) {
/* Direct inline call — compiler auto-vectorizes */
float dp = dot_product_f32(vf + i * dim, centroid[j], dim);
...
}
}For float32 input, to_float_block returns the source pointer directly
(zero-copy). For float16 input, it converts into a scratch buffer. Either way,
the inner loop operates on float * with a direct function call that the
compiler can inline and auto-vectorize with VS_TARGET_CLONES.
The C compiler applies two optimizations when an always_inline function
receives a compile-time-known function pointer to another always_inline
function:
- Inline the callee — the function pointer call becomes a direct call
- Copy-propagate — constant arguments flow into the inlined body
This is equivalent to C++ template monomorphization but without code bloat for cold paths. The libpas documentation calls this "specialization akin to template monomorphization."
In our case, we achieve the same effect more simply: by converting to a known
type at the batch boundary, the inner loop doesn't need function pointers at
all — it just operates on float * directly.