#27488

Allocation throughput does not scale across cores (default GC: 16 cores ≈ 1 core aggregate)

Openenghitaloopened 4mo ago

Context

For thread-per-core workloads (e.g. HTTP servers where each worker is pinned to a core and allocates independently), allocation throughput should scale roughly with core count. In practice V's allocation does not scale — and with the default GC it gets worse with more cores.

This is a discussion/RFC, not a crash bug. The data below is reproducible on master.

Benchmark

Each of N threads performs 500k []u8{len: 64} allocations in a tight loop (memory bounded by count — N × 500k × 64 B). We measure aggregate allocations/sec. If allocation scaled, aggregate would grow ~linearly with N and per-thread would stay roughly flat.

import time
import os

const alloc_size = 64

fn worker(count int, sink &u64) {
	mut acc := u64(0)
	for _ in 0 .. count {
		b := []u8{len: alloc_size}
		unsafe {
			b[0] = u8(acc) // touch so the alloc isn't optimised away
			acc += u64(b.data)
		}
	}
	unsafe { *sink = acc }
}

fn main() {
	nthreads := os.getenv_opt('NT') or { '1' }.int()
	count := os.getenv_opt('COUNT') or { '500000' }.int()
	mut sinks := []u64{len: nthreads}
	sw := time.new_stopwatch()
	mut threads := []thread{}
	for i in 0 .. nthreads {
		threads << spawn worker(count, unsafe { &sinks[i] })
	}
	threads.wait()
	secs := f64(sw.elapsed().nanoseconds()) / 1e9
	total := i64(nthreads) * i64(count)
	println('NT=${nthreads} aggregate=${f64(total) / secs:.0f}/s per_thread=${f64(total) / secs / f64(nthreads):.0f}/s time=${secs:.3f}s')
}

Run (pin to N cores):

v -prod -gc none -o as_none bench.v   # libc malloc
v -prod        -o as_boehm bench.v    # default GC (Boehm)
for nt in 1 2 4 8 16; do NT=$nt taskset -c 0-$((nt-1)) ./as_none;  done
for nt in 1 2 4 8 16; do NT=$nt taskset -c 0-$((nt-1)) ./as_boehm; done

Results (AMD Ryzen 7 5800H, 8 cores / 16 threads; 3× re-runs, stable)

threads-gc none aggregate/sper-threaddefault GC aggregate/sper-thread
113.0 M13.0 M18.5 M18.5 M
220.4 M10.2 M21.7 M10.8 M
433.3 M8.3 M18.1 M4.5 M
833.1 M4.1 M16.6 M2.1 M
1632.0 M2.0 M18.0 M1.15 M

Observations

  • Default GC (Boehm): aggregate throughput is essentially flat at ~18 M/s regardless of core count — 16 cores deliver the same total allocation throughput as a single core, and per-thread throughput collapses ~16×. Allocation is effectively serialized.
  • -gc none (libc malloc): scales ~2.5× from 1→4 threads, then plateaus past 4 cores (well below the 8 physical cores) — glibc per-arena lock / arena contention.

Discussion / questions

  1. Is this scaling profile expected for the default GC?
  2. Would thread-local allocation caches (or a per-thread arena in front of the GC) be in scope?
  3. Is an optional scalable allocator backend (e.g. mimalloc / jemalloc) selectable via -gc something the project would consider?
  4. Failing the above, would docs guidance for thread-per-core designs ("minimise per-request allocation; the allocator does not scale across cores") be welcome?

For the server this came from, the practical answer was to drive the hot path to zero allocations — but the underlying allocator scalability seemed worth surfacing.

V version

V 0.5.1 98bbdd7 (master)

OS

Linux x86_64 (AMD Ryzen 7 5800H, 8C/16T)

NOTE

You can use the 👍 reaction to increase the issue's priority for developers.

Please note that only the 👍 reaction to the issue itself counts as a vote. Other reactions and those to comments will not be taken into account.

Activity

0

No activity yet.