Repository navigation
cmd/compile: regalloc inserts redundant register swap in strings.Fields hot loop #81131
Description
Go version
go1.28-devel_e0badabf4a Tue Aug 25 17:51:39 2026 -0700
Output of go env in your module/workspace:
AR='ar'
CC='gcc'
CGO_CFLAGS='-O2 -g'
CGO_CPPFLAGS=''
CGO_CXXFLAGS='-O2 -g'
CGO_ENABLED='1'
CGO_FFLAGS='-O2 -g'
CGO_LDFLAGS='-O2 -g'
CXX='g++'
GCCGO='gccgo'
GO111MODULE=''
GOAMD64='v1'
GOARCH='amd64'
GOAUTH='netrc'
GOBIN='/home/aravinda/go/bin'
GOCACHE='/home/aravinda/.cache/go-build'
GOCACHEPROG=''
GODEBUG=''
GOENV='/home/aravinda/.config/go/env'
GOEXE=''
GOEXPERIMENT=''
GOFIPS140='off'
GOFLAGS=''
GOGCCFLAGS='-fPIC -m64 -pthread -Wl,--no-gc-sections -fmessage-length=0 -ffile-prefix-map=/tmp/go-build3992845761=/tmp/go-build -gno-record-gcc-switches'
GOHOSTARCH='amd64'
GOHOSTOS='linux'
GOINSECURE=''
GOMOD='/dev/null'
GOMODCACHE='/home/aravinda/go/pkg/mod'
GONOPROXY=''
GONOSUMDB=''
GOOS='linux'
GOPACKAGESDRIVER=''
GOPATH='/home/aravinda/go'
GOPRIVATE=''
GOPROXY='https://proxy.golang.org,direct'
GOROOT='/home/aravinda/upstream/go'
GOSUMDB='sum.golang.org'
GOTELEMETRY='local'
GOTELEMETRYDIR='/home/aravinda/.config/go/telemetry'
GOTMPDIR=''
GOTOOLCHAIN='auto'
GOTOOLDIR='/home/aravinda/upstream/go/pkg/tool/linux_amd64'
GOVCS=''
GOVERSION='go1.28-devel_e0badabf4a Tue Aug 25 17:51:39 2026 -0700'
GOWORK=''
PKG_CONFIG='pkg-config'What did you do?
On tip (go1.28-devel and also 1.27rc2), the register allocator emits a 3-instruction register swap on every iteration of the scan loop in strings.Fields (and FieldsFunc), where none is needed. The values are already in registers and stay in registers — the swap is pure wasted motion, not due to spilling.
The following reproducer is taken from the Fields function.
package ssa_test
var isDelim = [256]uint8{' ': 1, ',': 1}
//go:noinline
func ScanWords(s string, lens []int) int {
n := 0
start := 0
i := 0
for i < len(s) {
if isDelim[s[i]] == 0 {
i++
continue
}
if i > start {
lens[n] = i - start
n++
}
i++
start = i
}
if i > start {
lens[n] = i - start
n++
}
return n
}
Running go tool compile -S phicycle_test.go we will see a pattern similar to the following
MOVQ DX, R9 ; \
MOVQ R8, DX ; } swap DX <-> R8
MOVQ R9, R8 ; /
— 6 extra MOVQs per non-delimiter byte.
Similar pattern can be seen in ARM64 too
Edge shuffling seems to cause swapping on the hot path because of regs being flipped while allocating for the two phi vars i, start causing an extra 6 MOV instructions generated every iteration
Could be related to what is being worked on in #80201
But since the pattern is a bit different and occurs in Fields function, was not sure if it was the right thing to add it to 80201 hence opening an issue to track
Other variation of pattern where this is visible
package ssa_test
var lookup = [256]uint8{' ': 1, '\t': 1, '\n': 1}
//go:noinline
func ScanWords2(s string, lens []int) int {
n := 0
start := 0
i := 0
for i < len(s) {
c := lookup[s[i]]
if c == 0 {
// hot path: non-delimiter, only i advances
i++
continue
}
if c == 1 && i > start {
lens[n] = i - start
n++
}
i++
start = i
// no continue here — falls through to loop header
}
if i > start {
lens[n] = i - start
n++
}
return n
}
// Try a version with two explicit continues from different paths
//
//go:noinline
func ScanWords3(s string, lens []int) int {
n := 0
start := 0
i := 0
for i < len(s) {
if lookup[s[i]] == 0 {
i++
continue // back-edge 1: hot path
}
if i > start {
lens[n] = i - start
n++
i++
start = i
continue // back-edge 2: cold path with word
}
i++
start = i
// back-edge 3: cold path without word (falls through)
}
if i > start {
lens[n] = i - start
n++
}
return n
}
What did you see happen?
swaps being generated in a hot loop
What did you expect to see?
Ideally the swaps should not be generated especially in a hot loop
Metadata
Metadata
Assignees
Labels
Type
Projects
- StatusShow more project fieldsIn Progress
Activity
archanaravindar commentedon Aug 26, 2026
Tried a mechanism to resolve the contention in this case when multiple phis wanted the same register from the primary predecessor (because their inputs are the same SSA value), the first-come-first-served order above may have given the register to the wrong phi. Tried to consult other predecessors to break the tie: the phi whose non-primary input is already in the contested register has the stronger claim, because matching it avoids a shuffle on that edge.
but this approach caused a spill onto the stack which was even more expensive than what was originally occurring.
abypun commentedon Aug 28, 2026
@archanaravindar Consulting another predecessor can break the tie in this reproducer because the other predecessor happens to use the same register layout as the already-allocated successor. In general, however, it is not a robust tie-breaker: optimizing one incoming edge does not guarantee successor alignment and can merely trade an incoming-edge shuffle for an outgoing-edge one.
If that preference is enforced by narrowing the candidate mask, contention can leave a phi with no available register and degrade it to a stack-based phi. Depending on the implementation, this may explain the spill you observed.
A more direct signal is the register already assigned to a successor phi. If a phi in the current block is an argument to an already-allocated successor phi, that register is where the value must eventually reach on that edge.
I think we can use this as a soft register hint. Hinted phis would get the first chance to claim their hinted registers; if a hint is unavailable, allocation would fall back to the existing choices without narrowing the candidate set.
dr2chase commentedon Aug 28, 2026
@golang/compiler
gopherbot commentedon Aug 29, 2026
Change https://go.dev/cl/823585 mentions this issue:
cmd/compile: align merge block phi registers with allocated successorsJunyangShao commentedon Sep 1, 2026
I tested that CL 808220 and CL 809981 has no change on compiling the examples in this issue, so probably unrelated.