Performance Tuning
VeltrixDB's throughput and latency are governed by a small number of knobs: group commit and the WAL/VLog flush window, cache size, batching, and admission control; vector search adds its own (beam width, quantization, graph placement). This page summarizes the tuning guidance from the project's performance guide (PERFORMANCE.md) and docs/vector-search.md, including the index, C++ storage and network switches added in v1.6–v1.7 and the group-commit and vector knobs of v1.11–v1.12.
The most important knob: group commit Adaptive since v1.12
The WAL and the VLog both use group commit: concurrent writes share one fdatasync, and every write is acknowledged only after the sync that covers it.
| Flag | Default | Effect |
|---|---|---|
--group-commit | adaptive | adaptive: the flush window is an upper bound, not a wait. fixed: every batch waits the whole window (the pre-2026-10 behaviour) |
--wal-flush-window-ms | 15 | WAL group-commit window in ms; in adaptive mode only the upper bound on how long a busy batch may stay open |
--vlog-flush-window-ms | 15 | VLog group-commit window; must equal --wal-flush-window-ms |
--wal-max-batch | 4096 | Maximum WAL entries per flush; a full batch is flushed before the window ends |
Adaptive (default). A writer that is alone (one request in the batch and an EWMA of fewer than 1.5 requests per recent flush) is synced at once, so its latency is about one fdatasync. When writers are concurrent, a batch stays open until no new write has arrived for an idle gap equal to the EWMA of recent fdatasync times (clamped to 20 µs–2 ms), or the window expires, or the --wal-max-batch cap is reached (storage/group_commit.go). Measured with an emulated 300 µs device sync and the 15 ms window on macOS:
| Writers | Fixed 15 ms window | Adaptive |
|---|---|---|
| 1 | P50 16.0 ms, 63 writes/s | P50 0.45 ms, 2,186 writes/s |
| 8 | P50 16.0 ms, 501 writes/s | P50 0.96 ms, 8,283 writes/s |
| 64 | P50 15.9 ms, P99 16.8 ms, 4,053 writes/s | P50 1.7 ms, P99 2.6 ms, 37,504 writes/s |
| 256 | P99 33.2 ms, 15,291 writes/s | P99 11.5 ms, 73,999 writes/s |
Batches are as large as in fixed mode (64 writes per sync at 64 writers), so adaptive does not cost more IOPS under load. With a 2 ms sync (slow or network disk) 64 writers go 3,541 → 11,691 writes/s. Reproduce with VELTRIX_GC_TABLE=1 go test ./storage -run TestGroupCommit_LatencyTable -v; full table and conditions in Benchmarking. In adaptive mode the default 15 ms window is fine for almost everything.
Fixed (--group-commit=fixed). Only useful if you depend on its exact batching. Each writer waits up to one window, so:
writes/s ~ goroutines x (1000 / window_ms) # fixed mode
| Goal (fixed mode) | Window | P99 write |
|---|---|---|
| Lowest latency (low concurrency) | 0 ms | ~0.2 ms |
| Low latency | 2 ms | ~2.2 ms |
| 100K+ writes/s | 5 ms | ~5.2 ms |
| Default window | 15 ms | ~15.2 ms |
| Maximum throughput | 20 ms | ~20.2 ms |
P99 figures in the fixed table are Linux NVMe. Do not size against a macOS measurement: on Darwin the engine calls plain fsync(2), which returns at the drive cache in ~0.02 ms without flushing it (the adaptive table above emulates the sync cost for that reason).
./veltrixdb --group-commit=fixed --wal-flush-window-ms 5 --vlog-flush-window-ms 5
Batch operations
Always prefer MultiPut/MultiGet over individual PUT/GET calls when operating on multiple keys — they share a single network round trip.
| Method | Throughput | When to use |
|---|---|---|
| Individual PUT | ≈ 1 per fdatasync per goroutine (adaptive; 2,186/s at an emulated 300 µs sync), 1000 / window_ms in fixed mode | Single key |
| MultiPut, 1024 keys | ~108K/s (macOS) / ~426K/s (Linux) | Any multi-key write |
The MultiPut row is historical (measured before the one-write(2)-per-batch WAL flusher and the ordered-index fix). Current server numbers for 8 clients × 1024-key MPUT: 3.54M keys/s, P99 4.2 ms over a 1M-key space on macOS (1.45M keys/s, P99 9.6 ms before the ordered-index fix), and 1.77M keys/s, P99 12.2–12.7 ms on a 4-CPU CI runner (tmpfs, client on the same host, Go front-end, C++ storage off). New in v1.7
The binary-protocol handler coalesces pipelined PUT frames that are already buffered on one connection into a MultiPut (tryCoalescePuts, cmd/server/main.go), so pipelining binary clients get batch performance without code changes. Text-protocol PUTs, and a client that waits for each reply, are not coalesced.
Disk density and packing
Batched writes pack multiple records per 4 KB VLog block, which materially changes on-disk density at small value sizes:
| Avg value | Disk per record (packed) | Gain vs. legacy 4 KB |
|---|---|---|
| 64 B | 88 B | 47x |
| 128 B | 152 B | 27x |
| 256 B | 280 B | 15x |
| 512 B | 536 B | 7.6x |
| 4 KB+ | 4096 B | 1x |
Packing is automatic for MultiPut and pipelined binary PUTs. A single unbatched Put uses the unpacked lock-free path instead, which pads each record to a full 4 KB block. You can verify actual packing efficiency with veltrixdb_vlog_file_bytes / veltrixdb_storage_writes_total ~ value_size + 24.
Cache tuning
-cache 65536 # 64 GB
--auto-tune # cache = 85% of an 80%-of-RAM budget (~68% of RAM); ignores -cache
--read-heavy # 400 GB cache, LIRRatio 0.95, GC every 300 s, TTL scan every 60 s (-cache still wins)
--read-heavy is a preset for workloads dominated by reads where you can afford more RAM and want GC to run less often. An explicit -cache overrides its cache size.
Check cache effectiveness with veltrixdb_cache_hits_total / (hits + misses). Small values (≤256 B) resist eviction more aggressively under the LIRS policy, so workloads with many small values benefit the most from a larger cache.
Index memory and Go GC New in v1.6
On cgo builds (the default, including macOS and the official Docker image) the key index lives off the Go heap in per-shard C++ tables, so the Go garbage collector does not scan it. Measured with 5M keys: full GC 21 ms → 0.27 ms, settled RSS 168 → 142 B/key, at a cost of ~19 ns more per cache-miss lookup. Watch veltrixdb_storage_native_index_bytes: that memory is not in go_memstats_*.
VELTRIXDB_INDEX=map ./veltrixdb ... # opt out: Go map index (also what CGO_ENABLED=0 builds use)
sudo sysctl -p scripts/sysctl.conf # includes vm.max_map_count = 262144
Large key counts need vm.max_map_count ≥ 262144 (a large engine holds ~25K memory mappings); below that an insert can fail with ENOMEM.
C++ storage switches New in v1.7
| Env var | Default | Effect |
|---|---|---|
VELTRIXDB_URING_BRIDGE | off | on / sqpoll: submit VLog batch writes through io_uring (Linux cgo builds). Falls back to pwrite where io_uring_setup is blocked (e.g. Kubernetes RuntimeDefault seccomp). |
VELTRIXDB_DISABLE_CGO_ENGINE | unset | 1: no C++ batch engine, bridge forced off, Go map index. |
pwrite of one contiguous extent plus one fdatasync, so the bridge saves at most one syscall per batch, and SQPOLL spends a kernel thread busy-polling to save it. On a 4-CPU CI runner (tmpfs) the C++ storage layer with everything on (bridge with SQPOLL, batch engine, native index) measured 1.12M keys/s batch writes against 1.77M keys/s with all three off; per-part attribution is pending.
There are no --cgo-batch-engine, --numa-aware or --sqpoll-reader flags; passing one makes the server exit with "flag provided but not defined". Several C++ components in cpp/ — the priority io_uring scheduler, the SQPOLL VLog reader, the ART index, the hugepage slab allocator, mlockall, the eBPF GC throttle — compile but have no Go caller, so hugepage, fixed-buffer or SQPOLL-reader settings have no effect on the server. The read path is Go pread (4 KB-aligned, magic + CRC32C check).
Network front-end New in v1.7
--net=go (default) serves every protocol and command. --net=cpp|uring|poll is an opt-in, experimental C++ event-loop front-end (--net-threads, default one loop per core) that serves only the binary PUT GET DEL PING MPUT MGET commands, in standalone mode without --auth-config. Keys that need a VLog read are finished off the loop in a goroutine (VELTRIXDB_NET_DEFER_READS=0 restores the older inline behaviour for comparison). Load-test it with loadtest --proto=binary.
It is not faster overall. On a 4-CPU CI runner (tmpfs, client on the same host, measured 2026-09-25 before disk reads were moved off the loop): read P50 154 vs 286 µs and poll read throughput 194K vs 177K ops/s, but read P99 ~3.0 vs 1.8 ms and batch writes ~12% lower; io_uring did not beat poll. Compare on your own hardware with scripts/net-bench.sh (see Benchmarking). The server logs a per-stage latency breakdown of the C++ front-end at shutdown. Do not judge network changes on macOS: loopback caps round trips at ~60K/s for the Go and C++ front-ends alike.
Profiling New in v1.7
./veltrixdb --pprof-addr 127.0.0.1:6060 # CPU / heap / trace profiles; separate listener, off by default
go tool pprof http://127.0.0.1:6060/debug/pprof/profile?seconds=30
Admission control
VeltrixDB throttles background GC and, at the extreme, foreground writes based on an exponentially-weighted moving average (EWMA) of read latency, to keep GC from competing with foreground reads for NVMe bandwidth:
| Read EWMA | What happens |
|---|---|
| < 15 ms | Normal |
| ≥ 15 ms | GC bandwidth capped at 60 MB/s |
| > 20 ms | GC paused, and each PUT sleeps 2 ms |
| < 10 ms | Throttle cleared, GC resumes |
| No reads for 4+ minutes | EWMA considered stale — GC auto-resumes |
The GC bandwidth cap deliberately fires before the full pause, so GC is throttled progressively rather than stopped dead. If veltrixdb_storage_write_admission_throttles_total is rising, read EWMA has crossed 20 ms and writes are being intentionally slowed to protect read latency. Diagnose the underlying cause with veltrixdb_storage_read_latency_seconds before changing any other knob — raising the cache size or flush window will not help if the disk itself is the bottleneck.
Vector search tuning New in v1.11–v1.12
Commands and the data model are in Vector & Hybrid Search. The knobs, with their effect on GloVe-100 (first 100K words, 100-dim, recall@10 / p50, in-process, one query at a time, macOS):
| Knob | Default | Effect |
|---|---|---|
VSEARCH ... EF n | 64 (raised to k, and to 4 × k for quantized layouts) | The recall / latency knob. float32: ef 64 → 0.842 / 0.25 ms, ef 128 → 0.906 / 0.42 ms, ef 256 → 0.953 / 0.77 ms, ef 512 → 0.983 / 1.43 ms |
VCREATE ns dim QUANT int8 | float32 (QUANT none) | Same recall as float32 (0.839 / 0.905 / 0.952 / 0.983 at ef 64–512) at ~⅓ of the vector RAM; re-ranks the top 4 × k against the float32 vectors on disk (+0.1–0.3 ms at this size) |
VCREATE ... QUANT pq [PQM m] [PQTRAIN n] | PQM = dim/8 (range 1–dim); PQTRAIN 10,000 (min 256) | Gives up 1.4–4.5 points of recall@10 on GloVe-100 (ef 512 → 64) for ~⅐ of float32’s RAM at 768-dim; re-ranks the whole beam (max(4 × k, ef)) from disk. More PQM = better recall, more RAM. Until PQTRAIN vectors exist the namespace stores float32, then trains in the background |
VCREATE ... GRAPH disk | memory | Moves layer-0 edges (~97% of edge memory) to a memory-mapped scratch file under <first data dir>/vector-scratch; recall unchanged within noise. Helps once edges dominate RAM (many vectors, small codes) |
IDXCREATE name field | — | = filters on that field are served from the index instead of one record read per candidate |
--search-timeout-ms | 2000 | Per-peer timeout for one distributed search phase (BM25 has two). A peer that does not answer fails the request and is named in the error |
--search-allow-partial | false | Return results without the peers that failed or timed out (logged), and answer during the startup rebuild |
--search-fanout | true | false keeps searches local — only correct while every node holds every record (no rebalance has moved keys) |
Choosing a layout. Measured Go heap per 768-dim vector (10K synthetic clustered vectors, macOS, VELTRIX_VECTOR_MEMORY=1 go test ./storage -run TestVectorMemoryTable -v): float32 3,383 B, int8 1,078 B, pq (m = 96) 493 B, pq + disk graph 378 B heap + 216 B mapped. At 100-dim the quantized layouts are slower per query than float32 (they re-rank from the VLog) and save less, because graph edges dominate; they pay off at high dimension or large counts. Start with int8; use pq + GRAPH disk when RAM is the constraint and a few points of recall are acceptable, then raise EF or PQM to win recall back.
Sizing estimate (heap, N vectors of dimension d; the constant is graph edges and Go per-node overhead derived from the 768-dim measurement, so treat it as an estimate for other dimensions): float32 ≈ N × (4d + ~310 B), int8 ≈ N × (d + ~310 B), pq ≈ N × (m + ~400 B), pq + disk ≈ N × (m + ~280 B) plus 132 B per vector in the mapped file (up to 2× that while the file grows). Tested up to 100K real vectors and 10K at 768-dim; nothing at 10M+ yet.
INFO → search_ready). KV traffic is not affected.Benchmarks and reproduce commands: Benchmarking → Vector search.
Common problems
| Symptom | Check | Fix |
|---|---|---|
| Write P99 high | veltrixdb_storage_write_admission_throttles_total rising | Read latency too high — see admission control above |
| Cache hit rate low | veltrixdb_cache_evictions_total rising, cache_size_bytes ≈ cache_max_size_bytes | Increase -cache (or reclaim ~90 B/key with --disable-ordered-index and give it to the cache) |
| GC not running | veltrixdb_vlog_gc_skipped_paused_total rising | Wait up to 4 minutes for the stale EWMA to clear |
reads_total = 0 with live traffic | Binary version | Update — a fixed release restores the missing metrics call |
| False failure-detector alerts (single node) | fd.SetLocalNode not called | Call it before fd.Start() in your cluster setup |
| Writes slow at low concurrency (≈ one window per write) | --group-commit=fixed set | Use the default adaptive |
search indexes are still rebuilding after restart | INFO shows search_ready=0 search_loaded=x/y | Wait for the rebuild, or --search-allow-partial if incomplete results are acceptable |
search incomplete: N of M peers did not answer | The named peer is down or slow and not yet marked failed | Retry after the failure detector marks it, raise --search-timeout-ms, or --search-allow-partial |
| A PQ namespace uses as much RAM as float32 | Fewer than PQTRAIN vectors; the codebook is not trained yet | Expected until the threshold; lower PQTRAIN (≥ 256) for small namespaces |