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
| question | answer |
|---|---|
| do these scale on reads | all but the single mutex. tmap goes 42.8x over 48 cores, 89% of linear |
| which is fastest on reads | xsync, about 1.5x ahead of tmap at every core count |
| does that ranking hold | no. On read-mostly traffic tmap holds 78 Mops/s against xsync’s 57 |
| where does tmap lose | churn, 25.6 against xsync’s 48.1 at 48 cores |
| any surprises | churn 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:
| implementation | 1 core | 48 cores | scaling |
|---|---|---|---|
xsync.Map | 22.2 | 983.3 | 44.3x |
| tmap | 15.4 | 659.2 | 42.8x |
sync.Map | 6.7 | 295.1 | 44.0x |
| sharded RWMutex | 13.2 | 252.4 | 19.1x |
single RWMutex | 18.1 | 15.4 | 0.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.
| shards | cores | Mops/s | cores | Mops/s | change |
|---|---|---|---|---|---|
| 32 | 3 | 4.11 | 4 | 2.85 | -31% |
| 64 | 6 | 5.08 | 8 | 3.88 | -24% |
| 128 | 12 | 8.43 | 16 | 6.65 | -21% |
| 256 | 24 | 15.31 | 32 | 12.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.