Skip to content

runtime: process-global allocation profile counters contend under native parallel allocation #3056

Closed

Description

@cpunion

Reproduction

Native allocation accounting updates a process-global size bucket for every nonzero managed allocation. This is a scalability hot spot in an allocation-heavy four-worker wasmvm component service.

Pinned macOS arm64 / Apple M4 Max, Go1.27.3, LLGo 3d92a58e09bd8971899c6eaf63437fbecd18d432, -O3 -lto=full.

  • A separate wall-thread sample of the unmodified runtime records523 top-of-stack observations in recordMemProfileAlloc, the largest non-wait leaf in that sample. Waiting threads are included in the sample, so this is not a CPU percentage or causal end-to-end attribution.
  • AllocU, AllocAtomicU and aligned allocation always call this recorder. Size classes are scanned twice, and the chosen process-global counter is updated atomically.
  • arm64 disassembly ends in an ldaxr / add / stlxr / cbnz retry loop. Buckets occupy neighboring16-byte records.

An allocation-free reproduction mirrors the size-class recorder with public atomics. Each worker records1,000,00016-byte events. Workers and channels are prepared before the timer. Every shared/private case verifies the same exact aggregated event count. Median ns/event over three illustrative rounds follows.

Compiler 1worker shared 1worker private 4workers shared 4workers private
Go1.27.3 1.803 1.796 8.201 0.459
LLGo release 3.850 3.797 7.621 0.970

The mirrored LLGo recorder's shared/private ratio is7.85 at four workers. These are mechanism diagnostics. A patched runtime and end-to-end gain remain unmeasured.

Requested investigation

Keep accurate native allocation event reporting while reducing per-allocation process-global contention, for example through bounded per-thread accounting with correct snapshot/retirement aggregation. Direct size-class indexing could also remove repeated scans. Retain MemProfile and ReadMemStats.Mallocs semantics, including concurrent snapshots and exited threads.

Related work includes #2351 (sampled stack profiles), #2923 and #3043 (accurate native allocation events). #3043 reuses existing counters and adds no allocator work. This report concerns the pre-existing always-on counter cost. The pinned production revision was rebuilt and executed. Newer upstream runtime binaries remain unevaluated here.

Complete standalone program

package main

import (
	"encoding/json"
	"os"
	"runtime"
	"sync"
	"sync/atomic"
	"time"
)

type bucket struct {
	size    uintptr
	objects uint64
}

type recorder struct {
	buckets [27]bucket
	padding [16]uint64
}

// This reproduces LLGo 3d92a58's size-class counter algorithm using the public
// atomic API. Runtime allocation and garbage collection are outside the loop.
//
//go:noinline
func record(r *recorder, size uintptr) {
	if size == 0 {
		return
	}
	class := uintptr(16)
	if size > 16 {
		class = r.buckets[len(r.buckets)-1].size
		for i := range r.buckets {
			if size <= r.buckets[i].size {
				class = r.buckets[i].size
				break
			}
		}
	}
	for i := range r.buckets {
		if r.buckets[i].size == class {
			atomic.AddUint64(&r.buckets[i].objects, 1)
			return
		}
	}
}

type result struct {
	Compiler                              string
	Workers, Round                        int
	Mode                                  string
	Operations, ElapsedNs, VerifiedEvents uint64
}

func measure(workers, round int, mode string) result {
	const iterations = 1000000
	counters := make([]recorder, workers)
	for i := range counters {
		for j := range counters[i].buckets {
			counters[i].buckets[j].size = uintptr(16) << j
		}
	}
	ready := make(chan struct{}, workers)
	start := make(chan struct{})
	var done sync.WaitGroup
	done.Add(workers)
	for i := range workers {
		target := &counters[i]
		if mode == "shared" {
			target = &counters[0]
		}
		go func() {
			defer done.Done()
			ready <- struct{}{}
			<-start
			for range iterations {
				record(target, 16)
			}
		}()
	}
	for range workers {
		<-ready
	}
	begin := time.Now()
	close(start)
	done.Wait()
	elapsed := time.Since(begin).Nanoseconds()
	var events uint64
	for i := range counters {
		events += atomic.LoadUint64(&counters[i].buckets[0].objects)
	}
	expected := uint64(workers * iterations)
	if events != expected {
		panic("allocation event accounting differs")
	}
	return result{runtime.Compiler, workers, round, mode, expected, uint64(elapsed), events}
}

func main() {
	runtime.GOMAXPROCS(4)
	var rows []result
	for round := range 3 {
		for _, workers := range []int{1, 4} {
			modes := []string{"shared", "private"}
			if round%2 != 0 {
				modes = []string{"private", "shared"}
			}
			for _, mode := range modes {
				rows = append(rows, measure(workers, round, mode))
			}
		}
	}
	if err := json.NewEncoder(os.Stdout).Encode(rows); err != nil {
		panic(err)
	}
}
go build -o counter-go .
llgo build -O3 -lto=full -o counter-llgo .
./counter-go
./counter-llgo

Activity

  1. cpunion commented on Nov 11, 2026

    @cpunion
    CollaboratorAuthor

    A second, bounded runtime prototype supports the contention diagnosis. Both experiments use the same wasmvm source 0aaab1adfdb5a14d88a108bea06017fe0698a675, compiler binary 3d92a58, Go1.27.3, LLVM22.1.8 and -O3 -lto=full. The compiler/runtime copies are isolated from the production toolchain.

    The first prototype uses direct size-class indexing and128-byte bucket stride. Its separate36-process event moves owned LLGo throughput7854 ->8177req/s. The second keeps32 fixed128-byte atomic shards per native size bucket, selected from the numeric physical-thread slot. It performs no getg, G dereference, allocation or per-thread retirement bookkeeping. Snapshots aggregate all fixed shards. Bootstrap and hash collisions retain atomic counting. Host/wasm/bare-metal counter layouts retain their existing path.

    All original, first-prototype and sharded variants participate in the second fixed48-process event. Plain16-byte P3 HTTP,4workers,16clients,256requests/client,2warmups,7samples,3primary/A/A blocks, seed2026111161. All1,376,256 warm responses are checked. Startup/compile/first/warm/cleanup stay separate.

    Runtime req/s API us
    Original LLGo 7861 503.75
    Direct-index/padded LLGo 8224 481.55
    Sharded LLGo 12157 324.21
    Ordinary host/original LLGo 132815 1.91
    Ordinary host/sharded LLGo 132939 1.83
    Owned Go control 28756 138.26
    Wasmtime48.0.1 75979 51.60

    Sharded/original warm-time ratio0.6501 [0.6374,0.6654], an illustrative pointwise three-block bootstrap. Sharded A/A ratios are1.0149,0.9740,0.9943. Workstation noise and the small block count limit ranking/acceptance claims. The first and second events remain separate.

    A native probe checks131252 size-class values/boundaries, then records10000016-byte and10000032-byte events across four workers. MemProfile reports exactly100000 additional objects in each class. An additional unchanged-runtime observer brackets20000 checked requests. Go records1207.00 managed allocation events/request, LLGo3530.45, with zero events in the empty-observer control. These are allocation events, including short-lived objects. LLGo totals use its existing exact size-class recorder because native Mallocs remains zero before #3043.

    The prototype retains exact counts and atomic ordering. It adds a bounded roughly108KiB native static counter table and makes profile snapshots scan32 shards. Production remains on its pinned runtime. Cross-platform/native32 alignment, stress/race coverage and full upstream integration remain pending. The diff below is an experimental contribution draft.

    --- a/runtime/internal/runtime/memprofile.go
    +++ b/runtime/internal/runtime/memprofile.go
    @@ -26,8 +26,7 @@
     }
     
     type memProfileBucket struct {
    -	size uintptr
    -
    +	size    uintptr
     	objects memProfileCounter
     }
     
    @@ -61,36 +60,46 @@
     	{size: 1073741824},
     }
     
    -// Keep the bucket scan out of allocation call sites. Inlining it duplicates
    -// the scan and atomic update in every small allocator wrapper, which makes
    -// otherwise unrelated runtime backend changes perturb native binary size.
    -//
    -//go:noinline
    +// Per-allocation accounting touches only its selected cache line.
     func recordMemProfileAlloc(size uintptr) {
    -	if size == 0 {
    -		return
    -	}
    -	size = memProfileSizeClass(size)
    -	for i := range memProfileBuckets {
    -		b := &memProfileBuckets[i]
    -		if b.size == size {
    -			memProfileAddObject(&b.objects)
    -			return
    -		}
    +	if size != 0 {
    +		memProfileAddObject(&memProfileBuckets[memProfileBucketIndex(size)].objects)
     	}
     }
     
    +func memProfileBucketIndex(size uintptr) int {
    +	if size <= 16 {
    +		return 0
    +	}
    +	if size > 1<<30 {
    +		return len(memProfileBuckets) - 1
    +	}
    +	x := uint64(size-1) >> 4
    +	index := 1
    +	if x>>16 != 0 {
    +		x >>= 16
    +		index += 16
    +	}
    +	if x>>8 != 0 {
    +		x >>= 8
    +		index += 8
    +	}
    +	if x>>4 != 0 {
    +		x >>= 4
    +		index += 4
    +	}
    +	if x>>2 != 0 {
    +		x >>= 2
    +		index += 2
    +	}
    +	if x>>1 != 0 {
    +		index++
    +	}
    +	return index
    +}
    +
     func memProfileSizeClass(size uintptr) uintptr {
    -	if size <= 16 {
    -		return 16
    -	}
    -	for i := range memProfileBuckets {
    -		b := &memProfileBuckets[i]
    -		if size <= b.size {
    -			return b.size
    -		}
    -	}
    -	return memProfileBuckets[len(memProfileBuckets)-1].size
    +	return memProfileBuckets[memProfileBucketIndex(size)].size
     }
     
     func MemProfile(p []MemProfileRecord, inuseZero bool) (n int, ok bool) {
    --- a/runtime/internal/runtime/memprofile_atomic.go
    +++ b/runtime/internal/runtime/memprofile_atomic.go
    @@ -1,4 +1,4 @@
    -//go:build !baremetal
    +//go:build !baremetal && (!llgo || wasm)
     
     package runtime
     
    --- /dev/null
    +++ b/runtime/internal/runtime/memprofile_atomic_native.go
    @@ -0,0 +1,33 @@
    +//go:build llgo && !baremetal && !wasm
    +
    +package runtime
    +
    +import "github.com/xgo-dev/llgo/runtime/internal/sync/atomic"
    +
    +const memProfileShards = 32
    +
    +type memProfileCounterShard struct {
    +	_       memProfileAlignment
    +	value   uint64
    +	padding [15]uint64
    +}
    +
    +type memProfileCounter struct {
    +	shards [memProfileShards]memProfileCounterShard
    +}
    +
    +// Hash the physical-thread slot without initializing or dereferencing a G.
    +// Bootstrap/foreign contexts and hash collisions retain exact atomic counting.
    +func memProfileAddObject(p *memProfileCounter) {
    +	id := uint64(currentG)
    +	shard := (id>>4 ^ id>>11 ^ id>>19 ^ id>>27) & (memProfileShards - 1)
    +	atomic.Add(&p.shards[shard].value, uint64(1))
    +}
    +
    +func memProfileLoadObjects(p *memProfileCounter) uint64 {
    +	var total uint64
    +	for i := range p.shards {
    +		total += atomic.Load(&p.shards[i].value)
    +	}
    +	return total
    +}
  2. cpunion commented on Nov 11, 2026

    @cpunion
    CollaboratorAuthor

    The contribution is ready for review in #3057 at 689d36f08d1845f0c0a366d28fcf9cc9056ec091. It retains the existing recorder //go:noinline boundary. This final-source event uses compiler body e2cca2cb87860af43a588ba1cebc13984cda0897 for both variants and compares its base runtime against the contribution. The earlier 3d92a58 prototypes remain separate evidence.

    Fixed protocol, seed 2026111171, three paired primary/A/A blocks, 72 fresh processes. Plain16-byte Rust HTTP fixture from wasmvm 0aaab1adfdb5a14d88a108bea06017fe0698a675, SHA256 1d583dc670704a021f06d8437bc8834c8adb9feaf1a654cb16faa7e1de3bbdd2. Each participant uses 16 clients, 256 requests/client, 2 warmups, 7 samples and GOMAXPROCS=4. All 2,064,384 measured warm responses pass byte/result checks. Base/head use the same LLGo executable, Go1.27.3, LLVM22.1.8 and -O3 -lto=full.

    Runtime Workers req/s Mean public API μs A/A warm-time ratios
    base-owned 1 5,345 184.431 0.9972, 0.9946, 1.0132
    head-owned 1 5,339 185.123 0.9930, 0.9950, 0.9993
    go-owned 1 6,624 149.647 0.9845, 1.0007, 1.0016
    wasmtime 1 20,539 47.831 0.9963, 0.9929, 1.0059
    base-host 1 247,120 0.685 0.9611, 0.9709, 0.9837
    head-host 1 251,968 0.728 1.0811, 1.1413, 1.0754
    base-owned 4 8,440 468.883 1.0072, 1.0068, 1.0238
    head-owned 4 12,890 304.539 1.0378, 1.0097, 1.0141
    go-owned 4 26,581 149.889 1.0061, 0.9888, 1.0090
    wasmtime 4 74,931 52.234 0.9937, 0.9930, 1.0078
    base-host 4 131,674 1.658 1.0041, 1.0110, 1.0034
    head-host 4 129,531 1.990 1.0084, 0.9732, 0.9974

    Four-worker throughput improves 52.7%. The paired-block head/base warm-time ratio is 0.6516, with block ratios 0.6515, 0.6557, 0.6476. Single-worker paired ratio is 0.9978. All adverse observations and host controls are retained. Some A/A controls exceed the predeclared 2.5% band, especially ordinary single-worker host controls. Three workstation blocks support diagnosis and require broader validation for release acceptance.

    Startup, Wasm compilation, first call and cleanup are recorded independently. Four-worker Wasm compile/validate is 227.80 ms base and 232.21 ms head. Native LLGo executable build/link times are also separate. No startup or empty-call value is subtracted from execution timings.

    The contributed regression checks all small sizes and integer/power boundaries, exact concurrent counts, monotonic snapshots, worker exit and retained public allocation records. Focused tests pass on native macOS arm64 and local Docker Linux amd64. The broader Linux runtime, metrics, testing, sync and atomic suites pass all172 tests. macOS passes four companion packages and reproduces the base TestCallerLineDirectivesWithSameLine failure in the runtime package.

    #3043 consumes these existing counters through memProfileLoadObjects, making the two changes complementary. Its reporting fix and this hot-path scalability fix can be composed through the preserved helper API.

    The code-only integration of #3043 head dfd097dd4b7f8801dec8664ff49ad8da335422e0 and #3057 head 689d36f08d1845f0c0a366d28fcf9cc9056ec091 merges cleanly. Combined public Mallocs/AllocsPerRun/ReportAllocs, metrics and sharded-counter regression tests pass on macOS arm64 and local Docker Linux amd64 with full LTO.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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