Skip to content

Memory Management under gc none

Hitalo Souza edited this page Jun 18, 2026 · 1 revision

Memory Management under -gc none

This is the heart of vanilla's performance work. The server ships -prod -gc none, which means there is no garbage collector and nothing is ever freed automatically. Every heap allocation that isn't manually freed lives until the process exits. On a server under sustained load, a per‑request allocation is therefore not a cost — it is a leak that grows RSS without bound.

Why -gc none at all

Because allocation under a shared GC does not scale across cores, and vanilla is thread‑per‑core. See Gotchas and Lessons Learned#allocation-does-not-scale-across-cores and vlang/v#27488. The pipelined‑plaintext path reaches ~39.8 M req/s precisely because it touches the allocator zero times per request; under a GC that path pays collection/scan overhead it doesn't need.

The deal you accept in return: the hot paths must be genuinely allocation‑free. Anything that isn't shows up as linear RSS growth.

How to think about "a leak" under -gc none

The naïve metric — "RSS grows over time, therefore there's a leak" — is wrong here, because a few sources of growth are not collectable heap allocations at all: the glibc malloc arena grows and doesn't return memory to the OS, thread stacks and connection buffers reach a high‑water mark, etc. A Boehm‑GC build of the same server also shows some linear growth from these.

The metric that matters is RSS growth in excess of the Boehm GC floor. Build the server both ways, drive the same load, and subtract. What's left is the genuinely‑collectable per‑request allocation — the thing -gc none turns into a permanent leak. See Profiling and Benchmarking.

The zero‑allocation patterns

These are the recurring techniques used throughout the codebase to make a path allocation‑free:

Pattern Instead of Why
Reuse a per‑worker buffer (reset len=0, grow to high‑water, keep) a fresh []u8 per request the worker is single‑threaded; one buffer is safe and never re‑allocates after warmup
Borrow, don't copy — return tos/slice views into the read buffer .clone() / .bytes() a view is just a (ptr,len); the bytes are consumed synchronously before the buffer is recycled
Append bytes directlyunsafe { buf.push_many(s.str, s.len) } building a string/[]u8 then appending no intermediate object
Pool structs on a free‑list &T{} per request reuse the heap object across requests; reset its fields on release
Parse in place — index/offset arithmetic substrings / split no new array descriptors
No error‑boxing on the hot path — return -1, not !int find_byte (whose not‑found error() allocates) V boxes a MessageError for every error(), even when ignored by or {}
No empty/transient array literals — append elements, or use a const buf << [u8(0),0,0,0] a literal heap‑allocates a temporary array every call (this is true even for []T{}, see vlang/v#27487)

The campaign (how the DB path got to ~0)

The Postgres endpoints (async-db, fortunes, crud) were the worst offenders — they were allocating thousands of bytes per request, which under -gc none ballooned arena RSS to 27–44 GiB in the benchmark. The fix landed in stages, each verified by callgrind and an RSS‑slope benchmark.

Stage What Result (async‑db, bytes/request)
baseline 11,971
A per‑connection frames‑buffer pool (pg_async) + per‑worker render scratch 11,971 → 1,263
B put_cstr_s (no SQL copy per submit), reusable submit/param buffers, find_byte_idx, fortunes row views, Stash free‑list 1,263 → 159
C begin_msg byte‑append (not array literal); reactor watch‑queue buffer reuse (promote/orphan/drain) DB request path allocation‑free

Measured as excess over the Boehm floor, the per‑request DB leak went 181 → 11 → ~0 bytes/request; net vs the pre‑campaign baseline that's ~11,971 → ~0. Arena async‑db RSS dropped 44 GiB → ~1.2 GiB.

Two more pieces closed the remaining growth:

  • General/plaintext path (/baseline11, /upload): these built their body with n.str() — an int→string allocation per request (~6 GiB at 3.4 M req/s). Fixed with emit_int(), which formats the integer into the reused worker scratch (the same pattern the DB renders use).
  • Connection churn: load generators reconnect ~17–19 k times per run; each reconnect was allocating a ConnState + 8 KiB + 16 KiB buffers (and freeing them, churning the arena). The per‑worker ConnState free‑list took this from 3 allocations/reconnect → ~0 (callgrind, forced‑saturation reproduction). See Architecture#connection-pooling-free_conns.

A worked invariant: reusing a buffer is only safe if…

The single‑threaded‑worker assumption is what makes buffer reuse safe, but it has edges. The reactor's per‑fd ParkSlot queue is reused (len=0 reset + << refill) rather than re‑allocated each pipeline cycle. That is only correct because:

  • every push is provably gated to queue.len == 0, so a promoted head lands at index 0 exactly as the old [ParkSlot{…}] literal did;
  • every reader is len‑bounded, so physically‑retained slots beyond len are unreachable;
  • the FIFO drain (delete(0)) shifts in place without re‑allocating.

These invariants were checked by adversarial review before landing — the kind of thing that's cheap to get subtly wrong and expensive to debug in production. See Async Postgres and Pipelining.