Go version

go1.26-devel_2a7f1d47b0 linux/amd64

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=''
GOCACHE='/home/amusman/.cache/go-build'
GOCACHEPROG=''
GODEBUG=''
GOENV='/home/amusman/.config/go/env'
GOEXE=''
GOEXPERIMENT=''
GOFIPS140='off'
GOFLAGS=''
GOGCCFLAGS='-fPIC -m64 -pthread -Wl,--no-gc-sections -fmessage-length=0 -ffile-prefix-map=/tmp/go-build3835983328=/tmp/go-build -gno-record-gcc-switches'
GOHOSTARCH='amd64'
GOHOSTOS='linux'
GOINSECURE=''
GOMOD='/dev/null'
GOMODCACHE='/home/amusman/go/pkg/mod'
GONOPROXY=''
GONOSUMDB=''
GOOS='linux'
GOPATH='/home/amusman/go'
GOPRIVATE=''
GOPROXY='https://proxy.golang.org,direct'
GOROOT='/home/amusman/ws/gs'
GOSUMDB='sum.golang.org'
GOTELEMETRY='local'
GOTELEMETRYDIR='/home/amusman/.config/go/telemetry'
GOTMPDIR=''
GOTOOLCHAIN='auto'
GOTOOLDIR='/home/amusman/ws/gs/pkg/tool/linux_amd64'
GOVCS=''
GOVERSION='go1.26-devel_2a7f1d47b0 Tue Sep 2 16:27:43 2025 -0700'
GOWORK=''
PKG_CONFIG='pkg-config'

What did you do?

There are some tests related to number of statement lines (with percentage triggered by https://go-review.googlesource.com/c/go/+/608115 , however that's independent issue and we don't that CL to reproduce). This issue is to better document that and has corresponding CL https://go-review.googlesource.com/c/go/+/696675 suggesting a fix. Consider the following small example:

package main

type Data struct {
        Field1 int
        Field2 *int
        Field3 int
        Field4 *int
        Field5 int
        Field6 *int
        Field7 int
        Field8 *int
}

//go:noinline
func InitializeData(d *Data) {
        d.Field1++
        d.Field2 = d.Field4
        d.Field3++                    // line 18
        d.Field4 = d.Field6        // line 19
        d.Field5++
        d.Field6 = d.Field8
        d.Field7++                   // line 22
        d.Field8 = d.Field2       // line 23
}

func main() {
        var data Data
        InitializeData(&data)
}

Writebarrier lowering needs to split one block function InitializeData into several blocks and in this case offset calculation instruction (which contain statement marks) sometimes appear in top block (separate from their users).

What did you see happen?

Lines 18, 19, 22 and 23 in the above example have no stmt lines. For example (on amd64 target), it can be observed in ssa dump:

(18) v26 = ADDQconstmodify <mem> [val=1,off=16] v11 v19
(19) v29 = MOVQload <*int> [40] v11 v26

The offset was folded into stroring/loading instruction itself and the lower phase can in most cases preserve the offsets, however it does that only for offset from the same basic block.

What did you expect to see?

It would be better to preserve stmt line markers for these instructions.

Comment From: prattmic

cc @golang/compiler

Comment From: gopherbot

Change https://go.dev/cl/696675 mentions this issue: cmd/compile: propagate statement marks through write barrier splits

Comment From: gabyhelp

Related Issues

Related Code Changes

(Emoji vote if this was helpful or unhelpful; more detailed feedback welcome in this discussion.)

Comment From: randall77

I'm not seeing what you are here. Compiling your example, I see instructions with all of those lines. A selection:

    0x000e 00014 (/Users/khr/gowork/issue75249.go:16)   INCQ    (AX)
    0x002e 00046 (/Users/khr/gowork/issue75249.go:17)   MOVQ    CX, 8(AX)
    0x0032 00050 (/Users/khr/gowork/issue75249.go:18)   INCQ    16(AX)
    0x0053 00083 (/Users/khr/gowork/issue75249.go:19)   MOVQ    CX, 24(AX)
    0x0057 00087 (/Users/khr/gowork/issue75249.go:20)   INCQ    32(AX)
    0x0078 00120 (/Users/khr/gowork/issue75249.go:21)   MOVQ    CX, 40(AX)
    0x007c 00124 (/Users/khr/gowork/issue75249.go:22)   INCQ    48(AX)
    0x009d 00157 (/Users/khr/gowork/issue75249.go:23)   MOVQ    CX, 56(AX)

Stores should ~always maintain their line number, I think.

The write barrier code also looks to have the right line number. e.g., all the write barrier code generated from line 19 has line 19 in the assembly output.

Comment From: amusman

The lost part is 'boundary' for stepping in debugger, e.g. I can see the difference as following for the example above:

$ dlv exec --check-go-version=false -- ./ref
(dlv) b fld2.go:16
(dlv) c
> [Breakpoint 1] main.InitializeData() ./example/fld2.go:16
(dlv) n
> main.InitializeData() ./example/fld2.go:17
(dlv) n
> main.InitializeData() ./example/fld2.go:20
...

Lines 18 and 19 are not stopped when stepping through function using next debugger's command. With the suggested patch in CL all the lines with stores are stopped.

Comment From: randall77

Hm, then maybe the problem shows up in the generated dwarf? It does not show up in the -S output or the go tool objdump output. Both of those look as one would expect, all lines present and in order.

Comment From: amusman

One of possible ways to dump dwarf for the generated binary, e.g. on Mac go build -ldflags="-compressdwarf=false" -o ref src/fld2.go && dwarfdump --debug-line ref > ref.lines, it will print flags for each file/line - here are missing is_stmt flags on lines 18 and 19:

file_names[  2]:
           name: "fld2.go"
      dir_index: 1
       mod_time: 0x00000000
         length: 0x00000000

Address            Line   Column File   ISA Discriminator OpIndex Flags
------------------ ------ ------ ------ --- ------------- ------- -------------
0x0000000100068200     15      0      2   0             0       0  is_stmt
0x000000010006820c     15      0      2   0             0       0  is_stmt prologue_end
0x0000000100068218     16      0      2   0             0       0  is_stmt
0x000000010006821c     16      0      2   0             0       0
0x0000000100068224     17      0      2   0             0       0  is_stmt
0x0000000100068228     17      0      2   0             0       0
0x0000000100068244     18      0      2   0             0       0
0x0000000100068250     19      0      2   0             0       0
0x0000000100068270     20      0      2   0             0       0  is_stmt
0x0000000100068274     20      0      2   0             0       0
0x000000010006827c     21      0      2   0             0       0  is_stmt

But in ssa dump it looks more convenient, the is_stmt lines are marked with '+' sign.

Comment From: randall77

Ok, I dove into this a bit more.

I don't think this really has anything to do with writebarriers per se.

The source of the problem is that during CSE, we have two nilcheck operations that get CSEd, one of which is a statement marker and one of which isn't. We sometimes use the latter as the representative, which forces the statement marker off the nilcheck that is going away and onto its user, an OffPtr.

When we CSE two values, we should really pick the one with the statement marker as the representative, all else being equal.

See the mailed CL for a fix.

At a more fundamental level, how we fix up statement markers is a bit ad-hoc. During lower, we often combine things like (Load (OffPtr [c] ptr)) to (LoadWithOffset [c] ptr). If OffPtr has a statement marker, our rewrite engine only does some best-effort work to propagate the statement marker from the OffPtr to the LoadWithOffset. That best-effort work is particularly fragile if the OffPtr and Load are in different blocks (I think that's why this is showing up particularly for write barriers). But I'm not sure how to make this step more reliable, and it would probably be harder to solve than my fix for this issue.

Comment From: gopherbot

Change https://go.dev/cl/701295 mentions this issue: cmd/compile: when CSEing two values, prefer the statement marked one

Comment From: gopherbot

Change https://go.dev/cl/608115 mentions this issue: cmd/compile: CSE loads across disjoint stores

Comment From: amusman

@randall77 Thank you for the thorough analysis and the fix in CL 701295. Your identification of CSE as the root cause rather than write barriers per se was very insightful, and the fix works well for the example in this issue.

However, I have another related CL 608115 (cmd/compile: CSE loads across disjoint stores) where I'm seeing more cases of similar debugging information loss. Unfortunately, your suggested change in CL 701295, while effective here, doesn't fully address the broader set of cases that emerge with CL 608115 - the stmtlines_test.go still fails with that change applied.

This led me to explore an alternative approach. You mentioned that during CSE, statement markers get moved from the matched value to its user (often an OffPtr), which then leads to the fragile best-effort propagation during lowering that you described.

Since CSE matches NilChecks but doesn't actually remove them, I'm wondering if we could modify CSE to avoid moving statement boundary marks from NilCheck to the user in the first place. This would keep the statement markers there until nil check elimination pass, which better preserves them.

I've incorporated this approach into CL 608115, and it appears to address both the example in this issue the additional cases that emerge from the CSE load optimization.

Would you please take a look at CL 608115 when you have time (I've only added this nil check special handling)? I'm fine with extracting the NilCheck-related change to a separate CL, but putting it together may be better because the other changes in that CL add more cases where this fix is needed.