esmetrics

We Rewrote VictoriaMetrics in Rust. It Now Beats the Original on Every Benchmark Metric.

By Softalink  |  July 6, 2026

VictoriaMetrics is one of the fastest open-source time-series databases in existence. Its Go implementation is not naive code that a rewrite trivially beats — it is a decade of careful engineering around the garbage collector: object pools everywhere, unsafe byte↔string casts, buffer recycling as a way of life.

That made it the perfect subject for a question we wanted a rigorous answer to: how much performance is left on the table when the same algorithms run without a GC?

Today we're releasing the answer as EsMetrics, an Apache-2.0 licensed, from-scratch Rust implementation of VictoriaMetrics single-node (reference v1.146.0). On the TSBS benchmark suite it outperforms the original Go server on every metric — ingestion throughput and query latency across all ten devops query types — on both Linux and Windows, while answering queries byte-for-byte identically.

The numbers

TSBS cpu-only, scale 100 (8.64 million metrics), four workers, medians of three paired back-to-back rounds against the official v1.146.0 release binaries on identical hardware:

Ingestion

platform Go v1.146.0 EsMetrics delta
Linux (4 cores) 3.59M metrics/s 5.14M metrics/s +43%
Windows 11 (8 cores) 2.99M metrics/s 4.95M metrics/s +66%

Queries — all ten TSBS query types are faster. Mean latency improvements range from −11% to −59% on Linux and −52% to −82% on Windows. The heaviest query in the suite (double-groupby-all, aggregating a thousand series) went from 102ms to 41ms on Windows.

Correctness — 750 replayed TSBS queries produce byte-identical JSON to the Go server, down to Go's shortest-round-trip float formatting. Roughly 600 tests translated from the upstream Go suites run in CI on Linux and native Windows.

All raw per-round data, the harnesses, and the honest caveats live in the repo. The short version of the caveats: two machines, one workload family, and a handful of sub-millisecond stats that flip within the TSBS client clock's ~0.5ms Windows quantization.

Byte-identical or it didn't happen

The single most valuable decision in the project was a correctness rule: every response must match the Go server byte-for-byte.

That sounds obsessive. It is. It's also the only reason we can publish benchmark numbers with a straight face — a "faster" database that returns subtly different aggregates is just a broken database with good marketing.

The rule had teeth. It forced us to port Go's exact float formatting (Go's strconv produces shortest-round-trip decimal in fixed notation — 1e21 prints as 1000000000000000000000), the exact staleness-interval semantics of the rollup engine, the exact tie-breaking in the deduplication algorithm, and the exact JSON escaping of the response templates. A replay harness diffed our responses against the Go binary on identical data after every optimization. It caught more real bugs than the ported unit tests did.

Where the performance actually came from

The straight algorithmic port — faithful data structures, idiomatic Rust — won the ingestion benchmark immediately and lost most of the query benchmarks. Everything after that was profile-driven. A few of the changes that mattered:

1. The parallel unpacking pipeline. Go's netstorage collects per-series block references without decoding, then unpacks blocks across per-CPU workers. Our first port did all of that on the HTTP connection thread; the profile showed 26% of query time in series materialization and 30% in zstd/varint decode, all serialized. Porting the two-pass design — reference collection, then decode+merge across a persistent work-stealing pool with per-worker scratch buffers — cut the heaviest query's latency by 60%.

2. Caching what the upstream re-decodes. VictoriaMetrics decompresses data blocks on every query. We added a size-bounded, sharded decoded-block cache keyed by (part, offset), invalidated on part drop. This is a deliberate deviation from the faithful port — and it's worth 25-60% on read-heavy queries. Deterministic destruction made the invalidation story clean: when the last reference to a merged-away part drops, its cache entries go with it.

3. Allocation discipline where it counts. The ingest path is allocation-free at steady state: a zero-copy Influx parser that borrows from the request buffer (lifetimes prove what Go's unsafe casts merely hope), thread-local conversion arenas, and an ingestion API that never clones metric names. Sharded caches keyed by xxh64 with precomputed hashes — one hash per lookup — replaced the standard library's SipHash maps on the hot path.

4. Small mechanical wins that compound. SWAR varint decoding (eight bytes at a time instead of restart-per-byte). K-way merging of overlapping blocks instead of concatenate-and-stable-sort. A fair round-robin evaluation pool so a heavy query can't starve the sub-millisecond ones behind it.

The Windows cliff

The most instructive chapter was Windows. Under Wine, everything looked great. On real Windows hardware, multi-series queries were suddenly seven times slower than Go — 748ms for a query Go served in 102ms — while ingestion still won comfortably.

The profile pattern was strange: the degradation scaled linearly with series count, roughly half a millisecond per series. That's not an algorithm problem; that's a per-allocation problem. The Windows default process heap serializes concurrent allocations under a global lock. Go never notices — it ships its own allocator. Rust, by default, uses the system allocator.

One line — #[global_allocator] static GLOBAL: MiMalloc — took that query from 748ms to 81ms.

Windows had two more lessons that required structural fixes rather than tweaks. WinSock's shutdown() doesn't interrupt a thread blocked in recv the way POSIX does, so graceful shutdown needed ticked reads on idle connections. And deleting a merged-away data part's directory costs tens of milliseconds on Windows (hello, Defender) — which occasionally landed on whichever query thread happened to drop the last reference. The fix mirrors what the upstream does: a dedicated background remover thread, with drains at close boundaries so tests and restarts stay correct.

What the borrow checker found

Porting is a brutal code review. Two findings stand out.

The concurrency one: under concurrent ingestion, series registration had a window where two workers could assign different TSIDs to the same new series — in one benchmark load we counted 2,214 "shadow" series, which silently inflated every query that touched the affected hour. Striped single-flight locks closed it. The race pattern exists upstream in a milder form; the Rust port made it visible because our profiling kept asking why Windows queries unpacked 3.2× more series than expected.

The subtle one: our port of Go's regexp/syntax used char::to_lowercase/to_uppercase to approximate Go's unicode.SimpleFold. But simple-fold orbits must be closed cycles, and the std mappings aren't — U+212A (KELVIN SIGN) folds to k, but nothing folds back to it. Any while f != c orbit walk over a negated character class became an infinite loop. If you're implementing case-insensitive matching by hand: bound your orbit walks.

Scope, honestly

EsMetrics implements what the TSBS benchmark and standard Prometheus-style usage exercises: Influx line-protocol ingestion, the Prometheus query API, the full storage engine, retention, deduplication. It does not (yet) do clustering, the agent/alerting toolchain, other ingestion protocols, or the web UI. Versioning mirrors the upstream: EsMetrics 1.146.x tracks VictoriaMetrics v1.146.0, and a scripted sync process (baselines pinned, upstream diffs auto-mapped to the Rust modules that port them) is how it stays current.

Credits and an experiment note

EsMetrics is developed by Softalink LLC, with Claude (Anthropic) as an engineering contributor — the porting blueprints, the profile-driven optimization iterations, and the benchmark harness work were done in close human-AI collaboration. We think the result — a benchmark-complete, byte-identical port of a seriously optimized production database — is an interesting data point on what that collaboration can produce, and all of the evidence is public for scrutiny.

EsMetrics is Apache-2.0, a derivative work of VictoriaMetrics (Copyright VictoriaMetrics, Inc.). If your organization wants commercial support, sponsored features, or benchmark validation on your hardware — we'd love to hear from you.

#esmetrics#rust#database#time-series#performance ← All posts