8000
Skip to content

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

Description

@enghitalo

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/s per-thread default GC aggregate/s per-thread
1 13.0 M 13.0 M 18.5 M 18.5 M
2 20.4 M 10.2 M 21.7 M 10.8 M
4 33.3 M 8.3 M 18.1 M 4.5 M
8 33.1 M 4.1 M 16.6 M 2.1 M
16 32.0 M 2.0 M 18.0 M 1.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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions

    0