Repository navigation
Description
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,AllocAtomicUand 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/cbnzretry 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
cpunion commentedon Nov 11, 2026
A second, bounded runtime prototype supports the contention diagnosis. Both experiments use the same wasmvm source
0aaab1adfdb5a14d88a108bea06017fe0698a675, compiler binary3d92a58, 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.
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.
MemProfilereports 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 nativeMallocsremains 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.
cpunion commentedon Nov 11, 2026
The contribution is ready for review in #3057 at
689d36f08d1845f0c0a366d28fcf9cc9056ec091. It retains the existing recorder//go:noinlineboundary. This final-source event uses compiler bodye2cca2cb87860af43a588ba1cebc13984cda0897for both variants and compares its base runtime against the contribution. The earlier3d92a58prototypes remain separate evidence.Fixed protocol, seed
2026111171, three paired primary/A/A blocks, 72 fresh processes. Plain16-byte Rust HTTP fixture from wasmvm0aaab1adfdb5a14d88a108bea06017fe0698a675, SHA2561d583dc670704a021f06d8437bc8834c8adb9feaf1a654cb16faa7e1de3bbdd2. Each participant uses 16 clients, 256 requests/client, 2 warmups, 7 samples andGOMAXPROCS=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.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
TestCallerLineDirectivesWithSameLinefailure 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
dfd097dd4b7f8801dec8664ff49ad8da335422e0and #3057 head689d36f08d1845f0c0a366d28fcf9cc9056ec091merges cleanly. Combined public Mallocs/AllocsPerRun/ReportAllocs, metrics and sharded-counter regression tests pass on macOS arm64 and local Docker Linux amd64 with full LTO.