Go version
go version go1.26.2 windows/amd64
Output of go env in your module/workspace:
set AR=ar
set CC=gcc
set CGO_CFLAGS=-O2 -g
set CGO_CPPFLAGS=
set CGO_CXXFLAGS=-O2 -g
set CGO_ENABLED=1
set CGO_FFLAGS=-O2 -g
set CGO_LDFLAGS=-O2 -g
set CXX=g++
set GCCGO=gccgo
set GO111MODULE=on
set GOAMD64=v1
set GOARCH=amd64
set GOAUTH=netrc
set GOBIN=
set GOCACHE=C:\Users\Hesen\AppData\Local\go-build
set GOCACHEPROG=
set GODEBUG=
set GOENV=C:\Users\Hesen\AppData\Roaming\go\env
set GOEXE=.exe
set GOEXPERIMENT=
set GOFIPS140=off
set GOFLAGS=
set GOGCCFLAGS=-m64 -mthreads -Wl,--no-gc-sections -fmessage-length=0 -ffile-prefix-map=C:\Users\Hesen\AppData\Local\Temp\go-build684478256=/tmp/go-build -gno-record-gcc-switches
set GOHOSTARCH=amd64
set GOHOSTOS=windows
set GOINSECURE=
set GOMOD=F:\0myFile\OneDrive\0sync\mygo\go.mod
set GOMODCACHE=C:\Users\Hesen\go\pkg\mod
set GONOPROXY=
set GONOSUMDB=
set GOOS=windows
set GOPATH=C:\Users\Hesen\go
set GOPRIVATE=
set GOPROXY=https://goproxy.cn,direct
set GOROOT=D:\Program Files\Go
set GOSUMDB=sum.golang.org
set GOTELEMETRY=local
set GOTELEMETRYDIR=C:\Users\Hesen\AppData\Roaming\go\telemetry
set GOTMPDIR=
set GOTOOLCHAIN=auto
set GOTOOLDIR=D:\Program Files\Go\pkg\tool\windows_amd64
set GOVCS=
set GOVERSION=go1.26.2
set GOWORK=
set PKG_CONFIG=pkg-config
What did you do?
Summary
When a small map (with m.dirLen == 0) contains exactly 8 elements (m.used == abi.MapGroupSlots), any subsequent assignment triggers an immediate growth to a table via m.growToTable(typ), regardless of whether the key already exists in the map. This leads to unnecessary O(N) evacuation and memory allocation for an O(1) in-place update.
Description
In Go 1.26's Swiss Table implementation (specifically in internal/runtime/maps/runtime.go), the growth check in runtime_mapassign (and its variants in runtime_fast*.go) is performed before checking if the key is already present:
if m.dirLen == 0 {
if m.used < abi.MapGroupSlots {
// ... logical path for small map insertion/update
return elem
}
// Can't fit another entry, grow to full size map.
m.growToTable(typ)
}
This behavior causes an unnecessary growth when updating an existing key in a full small map, which should instead be an in-place update.
Root Cause
The growth check is performed before checking if the key already exists, which means even for updates to existing keys, the map will grow if it's at capacity. This is inefficient because updates don't require additional space.
I found a TODO (@prattmic) in internal/runtime/maps/map.go which indicates that this limitation was previously recognized but not yet addressed:
// TODO(prattmic): If this is an update to an existing key then
// we actually don't need to grow.
m.growToTable(typ)
Benchmarks
I conducted benchmarks on go version go1.26.2 windows/amd64 to measure the performance impact of this issue.
Test Scenarios
UpdateExisting: Updating an existing key in a full small map (currently triggers growth)
InsertWithGrow: Inserting a 9th key (rightfully triggers growth)
InsertWithoutGrow: Deleting a key then inserting it (avoids growth)
Benchmark Results
I modified the local Go map implementation to fix this issue and then conducted performance tests for comparison.
Running on windows/amd64, AMD Ryzen 7 9700X:
| Test Case |
Before (sec/op) |
After (sec/op) |
Delta |
UpdateExisting |
496.80n ± 13% |
75.73n ± 1% |
-84.76% |
InsertWithGrow |
496.7n ± 1% |
515.1n ± 1% |
+3.70% |
InsertWithoutGrow |
71.79n ± 1% |
71.92n ± 1% |
~ |
| Test Case |
Before (B/op) |
After (B/op) |
Delta |
UpdateExisting |
328.0 |
0.0 |
-100.00% |
InsertWithGrow |
328.0 |
328.0 |
~ |
InsertWithoutGrow |
0.0 |
0.0 |
~ |
Full Benchstat Output
goos: windows
goarch: amd64
pkg: mygo/test
cpu: AMD Ryzen 7 9700X 8-Core Processor
│ .\old.txt │ .\new.txt │
│ sec/op │ sec/op vs base │
SmallMapUpdate/UpdateExisting-16 496.80n ± 13% 75.73n ± 1% -84.76% (p=0.000 n=10)
SmallMapUpdate/InsertWithGrow-16 496.7n ± 1% 515.1n ± 1% +3.70% (p=0.000 n=10)
SmallMapUpdate/InsertWithoutGrow-16 71.79n ± 1% 71.92n ± 1% ~ (p=0.183 n=10)
geomean 260.7n 141.0n -45.90%
│ .\old.txt │ .\new.txt │
│ B/op │ B/op vs base │
SmallMapUpdate/UpdateExisting-16 328.0 ± 0% 0.0 ± 0% -100.00% (p=0.000 n=10)
SmallMapUpdate/InsertWithGrow-16 328.0 ± 0% 328.0 ± 0% ~ (p=1.000 n=10) ¹
SmallMapUpdate/InsertWithoutGrow-16 0.000 ± 0% 0.000 ± 0% ~ (p=1.000 n=10) ¹
geomean ² ? ² ³
¹ all samples are equal
² summaries must be >0 to compute geomean
³ ratios must be >0 to compute geomean
│ .\old.txt │ .\new.txt │
│ allocs/op │ allocs/op vs base │
SmallMapUpdate/UpdateExisting-16 3.000 ± 0% 0.000 ± 0% -100.00% (p=0.000 n=10)
SmallMapUpdate/InsertWithGrow-16 3.000 ± 0% 3.000 ± 0% ~ (p=1.000 n=10) ¹
SmallMapUpdate/InsertWithoutGrow-16 0.000 ± 0% 0.000 ± 0% ~ (p=1.000 n=10) ¹
geomean ² ? ² ³
¹ all samples are equal
² summaries must be >0 to compute geomean
³ ratios must be >0 to compute geomean
The data shows that after the fix, updating an existing element in a full small map no longer triggers allocations and is significantly faster, matching the performance of a standard in-place update.
Proposed Fix
I have implemented a fix that modifies the m.dirLen == 0 path to first probe the existing slots for a key match before calling m.growToTable. This ensures that:
- Updates remain in-place without growth.
- Insertions still trigger growth once the group is truly full.
The fix has been applied to the following files:
internal/runtime/maps/runtime.go
internal/runtime/maps/map.go
internal/runtime/maps/runtime_fast32.go
internal/runtime/maps/runtime_fast64.go
internal/runtime/maps/runtime_faststr.go
internal/runtime/maps/map_test.go (added a test function)
CL Ready
I have the CL ready and would like to submit it for review. The fix is minimal and focused, only changing the necessary code to address the issue while maintaining compatibility with existing code.
What did you see happen?
As described above, a full small map (with 8 elements) triggers unexpected growth behavior when performing an update operation.
What did you expect to see?
As described above, a full small map should not trigger growth when updating an existing element; growth should only occur when writing a new element.
Go version
go version go1.26.2 windows/amd64
Output of
go envin your module/workspace:What did you do?
Summary
When a small map (with
m.dirLen == 0) contains exactly 8 elements (m.used == abi.MapGroupSlots), any subsequent assignment triggers an immediate growth to a table viam.growToTable(typ), regardless of whether the key already exists in the map. This leads to unnecessary O(N) evacuation and memory allocation for an O(1) in-place update.Description
In Go 1.26's Swiss Table implementation (specifically in
internal/runtime/maps/runtime.go), the growth check inruntime_mapassign(and its variants inruntime_fast*.go) is performed before checking if the key is already present:This behavior causes an unnecessary growth when updating an existing key in a full small map, which should instead be an in-place update.
Root Cause
The growth check is performed before checking if the key already exists, which means even for updates to existing keys, the map will grow if it's at capacity. This is inefficient because updates don't require additional space.
I found a TODO (@prattmic) in
internal/runtime/maps/map.gowhich indicates that this limitation was previously recognized but not yet addressed:Benchmarks
I conducted benchmarks on
go version go1.26.2 windows/amd64to measure the performance impact of this issue.Test Scenarios
UpdateExisting: Updating an existing key in a full small map (currently triggers growth)InsertWithGrow: Inserting a 9th key (rightfully triggers growth)InsertWithoutGrow: Deleting a key then inserting it (avoids growth)Benchmark Results
I modified the local Go map implementation to fix this issue and then conducted performance tests for comparison.
Running on
windows/amd64,AMD Ryzen 7 9700X:UpdateExistingInsertWithGrowInsertWithoutGrowUpdateExistingInsertWithGrowInsertWithoutGrowFull Benchstat Output
The data shows that after the fix, updating an existing element in a full small map no longer triggers allocations and is significantly faster, matching the performance of a standard in-place update.
Proposed Fix
I have implemented a fix that modifies the
m.dirLen == 0path to first probe the existing slots for a key match before callingm.growToTable. This ensures that:The fix has been applied to the following files:
internal/runtime/maps/runtime.gointernal/runtime/maps/map.gointernal/runtime/maps/runtime_fast32.gointernal/runtime/maps/runtime_fast64.gointernal/runtime/maps/runtime_faststr.gointernal/runtime/maps/map_test.go(added a test function)CL Ready
I have the CL ready and would like to submit it for review. The fix is minimal and focused, only changing the necessary code to address the issue while maintaining compatibility with existing code.
What did you see happen?
As described above, a full small map (with 8 elements) triggers unexpected growth behavior when performing an update operation.
What did you expect to see?
As described above, a full small map should not trigger growth when updating an existing element; growth should only occur when writing a new element.