Skip to content

cmd/compile: regalloc inserts redundant register swap in strings.Fields hot loop #81131

Open

Description

@archanaravindar

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

Activity

  1. archanaravindar commented on Aug 26, 2026

    @archanaravindar
    ContributorAuthor

    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.

  2. abypun commented on Aug 28, 2026

    @abypun
    Contributor

    @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.

  3. dr2chase commented on Aug 28, 2026

    @dr2chase
    Contributor

    @golang/compiler

  4. added
    NeedsInvestigationSomeone must examine and confirm this is a valid issue and not a duplicate of an existing one.
    on Aug 28, 2026
  5. gopherbot commented on Aug 29, 2026

    @gopherbot
    Contributor

    Change https://go.dev/cl/823585 mentions this issue: cmd/compile: align merge block phi registers with allocated successors

  6. JunyangShao commented on Sep 1, 2026

    @JunyangShao
    Contributor

    I tested that CL 808220 and CL 809981 has no change on compiling the examples in this issue, so probably unrelated.

  7. added this to the Go1.28 milestone on Sep 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    NeedsInvestigationSomeone must examine and confirm this is a valid issue and not a duplicate of an existing one.Performancecompiler/runtimeIssues related to the Go compiler and/or runtime.

    Type

    No type

    Projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions