VeltrixDB Docs
v1.16 GitHub FAQ Quickstart

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.

FlagDefaultEffect
--group-commitadaptiveadaptive: 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-ms15WAL group-commit window in ms; in adaptive mode only the upper bound on how long a busy batch may stay open
--vlog-flush-window-ms15VLog group-commit window; must equal --wal-flush-window-ms
--wal-max-batch4096Maximum 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:

WritersFixed 15 ms windowAdaptive
1P50 16.0 ms, 63 writes/sP50 0.45 ms, 2,186 writes/s
8P50 16.0 ms, 501 writes/sP50 0.96 ms, 8,283 writes/s
64P50 15.9 ms, P99 16.8 ms, 4,053 writes/sP50 1.7 ms, P99 2.6 ms, 37,504 writes/s
256P99 33.2 ms, 15,291 writes/sP99 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)WindowP99 write
Lowest latency (low concurrency)0 ms~0.2 ms
Low latency2 ms~2.2 ms
100K+ writes/s5 ms~5.2 ms
Default window15 ms~15.2 ms
Maximum throughput20 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.

MethodThroughputWhen to use
Individual PUT≈ 1 per fdatasync per goroutine (adaptive; 2,186/s at an emulated 300 µs sync), 1000 / window_ms in fixed modeSingle 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 valueDisk per record (packed)Gain vs. legacy 4 KB
64 B88 B47x
128 B152 B27x
256 B280 B15x
512 B536 B7.6x
4 KB+4096 B1x

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 varDefaultEffect
VELTRIXDB_URING_BRIDGEoffon / 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_ENGINEunset1: no C++ batch engine, bridge forced off, Go map index.
Measure before enabling the io_uring bridge A VLog batch is already one 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 EWMAWhat happens
< 15 msNormal
≥ 15 msGC bandwidth capped at 60 MB/s
> 20 msGC paused, and each PUT sleeps 2 ms
< 10 msThrottle cleared, GC resumes
No reads for 4+ minutesEWMA 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):

KnobDefaultEffect
VSEARCH ... EF n64 (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 int8float32 (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 diskmemoryMoves 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-ms2000Per-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-partialfalseReturn results without the peers that failed or timed out (logged), and answer during the startup rebuild
--search-fanouttruefalse 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.

Plan restarts of large namespaces The graph and inverted index are not saved; they are rebuilt from the persisted keys at startup, and vector / text / hybrid searches are refused until the rebuild finishes (INFO → search_ready). KV traffic is not affected.

Benchmarks and reproduce commands: Benchmarking → Vector search.

Common problems

SymptomCheckFix
Write P99 highveltrixdb_storage_write_admission_throttles_total risingRead latency too high — see admission control above
Cache hit rate lowveltrixdb_cache_evictions_total rising, cache_size_bytes ≈ cache_max_size_bytesIncrease -cache (or reclaim ~90 B/key with --disable-ordered-index and give it to the cache)
GC not runningveltrixdb_vlog_gc_skipped_paused_total risingWait up to 4 minutes for the stale EWMA to clear
reads_total = 0 with live trafficBinary versionUpdate — a fixed release restores the missing metrics call
False failure-detector alerts (single node)fd.SetLocalNode not calledCall it before fd.Start() in your cluster setup
Writes slow at low concurrency (≈ one window per write)--group-commit=fixed setUse the default adaptive
search indexes are still rebuilding after restartINFO shows search_ready=0 search_loaded=x/yWait for the rebuild, or --search-allow-partial if incomplete results are acceptable
search incomplete: N of M peers did not answerThe named peer is down or slow and not yet marked failedRetry after the failure detector marks it, raise --search-timeout-ms, or --search-allow-partial
A PQ namespace uses as much RAM as float32Fewer than PQTRAIN vectors; the codebook is not trained yetExpected until the threshold; lower PQTRAIN (≥ 256) for small namespaces