hop 03, notes

How a sharded map scales to 48 cores, and where the shard heuristic bites

host
epyc host
cpu
AMD EPYC 7642, 48 cores, 16 NUMA nodes of 3, SMT off
go
go1.26.5 linux/amd64
governor
performance

A concurrent map benchmarked at one core count tells you about that core count. I swept six implementations across eleven counts, 1 core to 48. The thing I was not expecting only shows up along that axis: on the write-heavy workload, throughput drops every time the core count crosses a power of two.

44 benchmarks per core count, five samples per cell, 2,420 measurements in about 40 minutes. Core counts follow this host’s coherence boundary rather than powers of two. 16 NUMA nodes of 3 cores puts the first boundary at 3, so the sweep runs 1, 2, 3, 4, 6, 8, 12, 16, 24, 32 and 48. Each of the five passes takes one sample per cell and then starts the matrix over, so a cell’s samples sit about nine minutes apart instead of back to back, and drift shows up as spread rather than as signal.

I report five of the six here: sync.Map, xsync.Map, a plain map behind one sync.RWMutex, a hand-sharded map behind per-shard RWMutexes, and tmap, a sharded map of mine that is not released. The sixth holds word-sized values only and cannot take the string value class, so it sits out.

The answer first

questionanswer
do these scale on readsall but the single mutex. tmap goes 42.8x over 48 cores, 89% of linear
which is fastest on readsxsync, about 1.5x ahead of tmap at every core count
does that ranking holdno. On read-mostly traffic tmap holds 78 Mops/s against xsync’s 57
where does tmap losechurn, 25.6 against xsync’s 48.1 at 48 cores
any surpriseschurn throughput is not monotonic in core count

Reads scale, and the ranking is a constant factor

tmap goes from 15.4 to 659.2 Mops/s across 48 cores, which is 42.8x, or 89% of linear. xsync manages 44.3x, 92%, and is ahead everywhere, ending at 983.3. sync.Map scales at a similar rate and sits about 2.2x below tmap the whole way. The gap between the top two barely moves across the sweep, so it is a per-operation cost difference and not a scaling difference.

Uniform reads, int64 values, Mops/s:

implementation1 core48 coresscaling
xsync.Map22.2983.344.3x
tmap15.4659.242.8x
sync.Map6.7295.144.0x
sharded RWMutex13.2252.419.1x
single RWMutex18.115.40.85x

The single mutex is the control that proves the axis is real. It is the fastest arm at one core and never gains anything from a second: 18.1 Mops/s at one core, 15.4 at 48, flat to slowly falling the whole way, on every workload.

The hand-sharded map has a stranger shape. It scales on pure reads and on churn, but on both mixed workloads it loses throughput with every core added, going from 27.6 Mops/s at one core to 8.0 at 48. Whatever it contends on is reached by the mixed traffic and not by either extreme.

Where the ranking reverses

On read-mostly traffic tmap reaches 74 Mops/s at six cores, then holds between 75 and 78 from eight cores out to 48, while xsync plateaus around 57. That is the reverse of the read-only ranking, so “which map is faster” has no answer that survives across workloads.

On churn, xsync is ahead by 1.9x at 48 cores, 48.1 against 25.6, and climbs smoothly where tmap does not.

The churn sawtooth

tmap sizes its shards once, at construction, as nextPow2(GOMAXPROCS * 8). Rounding to a power of two means adjacent core counts share a shard count, and in every pair that does, the lower core count is faster.

Adding cores without adding shards costs churn throughput Churn workload, int64 values, median of 5 samples. Each pair shares a shard count. Higher is better. 10.7 shards per core8 shards per core 32 shards 3 cores against 4 32 shards · 10.7 shards per core: 4.11 Mops/s 4.11 32 shards · 8 shards per core: 2.85 Mops/s 2.85 64 shards 6 against 8 64 shards · 10.7 shards per core: 5.08 Mops/s 5.08 64 shards · 8 shards per core: 3.88 Mops/s 3.88 128 shards 12 against 16 128 shards · 10.7 shards per core: 8.43 Mops/s 8.43 128 shards · 8 shards per core: 6.65 Mops/s 6.65 256 shards 24 against 32 256 shards · 10.7 shards per core: 15.31 Mops/s 15.31 256 shards · 8 shards per core: 12.01 Mops/s 12.01
shardscoresMops/scoresMops/schange
3234.1142.85-31%
6465.0883.88-24%
128128.43166.65-21%
2562415.313212.01-22%

The left column of each pair has 10.7 shards per core and the right has 8, so throughput here tracks shards per core rather than cores. Four boundaries in the sweep, four occurrences.

Spread across the five samples is 1.3% to 5.4% of the median at the three larger boundaries, which puts those gaps well outside noise. The 3-against-4 pair is much noisier, 24% and 30%, and is the one to lean on least: its two sample ranges stay disjoint, 3.57 low against 3.44 high, but only just.

This shows up on churn alone. Both mixed workloads are smooth across the same core counts, which is what a shard-contention cause predicts and a cache or NUMA cause does not.

It is not a correctness problem and it may be inherent to power-of-two masking. It is worth knowing that a service moving from 24 cores to 32 can lose write throughput, and that no benchmark run at a single core count can see it.

What this does not claim

Shard count moves with core count by design, so these are curves for tmap as it configures itself at N cores, not isolated core scaling. Separating the two needs a fixed-shard arm, which has not been run.

Runs are unpinned, so thread placement across the 16 CCXs is the scheduler’s choice, and aggregate L3 grows from 16 MiB at 3 cores to 256 MiB at 48. Scaling efficiency crosses 1.0 on the read workloads partly for that reason: a working set that missed cache at one core increasingly fits by 48. It affects every arm equally, so the comparisons hold, but the absolute efficiency figures flatter everyone.

The 48-core column of this sweep was checked against an earlier run of the same benchmark on the same code, as a control. Every tmap row came back statistically indistinguishable except read-mostly at -2.3%, and the rows that did move were mostly the contended comparators that already carry wide intervals. That is what licenses reading the other ten columns.

© 2026 Tumult1337 RSS