Go version
go version devel go1.27-5d6aa23e5b (tip)
Also reproducible on go1.24.6 and earlier — this has been the behavior since the optimization was first introduced.
What operating system and processor architecture are you using?
darwin/arm64 (Apple M1 Max). Also affects amd64 and other architectures.
What did you do?
A common pattern in Go services is to populate a struct and store it through a pointer, into a slice, or into a global:
package p
type User struct {
ID int
Name string
Email string
Age int
IsActive bool
Balance float64
CreatedAt int64
UpdatedAt int64
Country string
City string
PhoneNumber string
Department string
}
var ptr *User
//go:noinline
func storeUserWithStruct(id int, name string, email string, age int, isActive bool, balance float64, createdAt int64, updatedAt int64, country string, city string, phoneNumber string, department string) {
*ptr = User{
ID: id,
Name: name,
Email: email,
Age: age,
IsActive: isActive,
Balance: balance,
CreatedAt: createdAt,
UpdatedAt: updatedAt,
Country: country,
City: city,
PhoneNumber: phoneNumber,
Department: department,
}
}
//go:noinline
func storeUserFieldByField(id int, name string, email string, age int, isActive bool, balance float64, createdAt int64, updatedAt int64, country string, city string, phoneNumber string, department string) {
ptr.ID = id
ptr.Name = name
ptr.Email = email
ptr.Age = age
ptr.IsActive = isActive
ptr.Balance = balance
ptr.CreatedAt = createdAt
ptr.UpdatedAt = updatedAt
ptr.Country = country
ptr.City = city
ptr.PhoneNumber = phoneNumber
ptr.Department = department
}
These two functions are semantically equivalent. Compiled with go build -gcflags='-S' repro.go.
What did you expect to see?
Both functions should generate similar code — direct field stores to the destination.
For a local variable, oaslit in walk/complit.go already decomposes x := S{A: a, B: b} into x.A = a; x.B = b (avoiding a temporary). The same decomposition should apply when the LHS is any addressable target.
What did you see?
storeUserWithStruct allocates a large stack frame with a stack temporary for the entire struct. It zeroes the temporary, fills each field into the temporary, then does a single wbMove (bulk copy with write barrier) to copy the entire struct to *ptr:
; storeUserWithStruct — current compiler output (arm64)
TEXT storeUserWithStruct(SB), $192-144 ; ← 192-byte frame (144 bytes for stack temp)
; Step 1: Zero the 144-byte stack temporary (9 STP instructions)
MOVD $autotmp_12-144(SP), R15
STP (ZR, ZR), (R15)
STP (ZR, ZR), 16(R15)
STP (ZR, ZR), 32(R15)
STP (ZR, ZR), 48(R15)
STP (ZR, ZR), 64(R15)
STP (ZR, ZR), 80(R15)
STP (ZR, ZR), 96(R15)
STP (ZR, ZR), 112(R15)
STP (ZR, ZR), 128(R15)
; Step 2: Fill all 12 fields into the temporary
STP (R0, R1), autotmp_12-144(SP) ; temp.ID, temp.Name.ptr
STP (R2, R3), autotmp_12-128(SP) ; temp.Name.len, temp.Email.ptr
; ... 8 more stores to temp ...
; Step 3: Bulk copy temp → *ptr (144 bytes with write barrier)
MOVD ptr(SB), R1
CALL runtime.wbMove(SB) ; ← expensive bulk copy of entire struct
Meanwhile storeUserFieldByField stores directly to *ptr with individual write barriers — no temporary, no bulk copy:
; storeUserFieldByField — current compiler output (arm64)
TEXT storeUserFieldByField(SB), $16-144 ; ← 16-byte frame, no temporary
MOVD ptr(SB), R14 ; load pointer once
MOVD R0, (R14) ; ptr.ID = id (direct store)
; ... individual field stores with per-field write barriers ...
The struct literal form is ~2x slower than the field-by-field form in benchmarks. On an 8-field struct:
BenchmarkStructLitAssign/SliceLiteral-10 651735466 9.042 ns/op
BenchmarkStructLitAssign/SliceFieldByField-10 1000000000 4.553 ns/op
The same gap applies to all non-local addressable LHS targets (globals, pointer deref, struct fields through pointer).
Root cause
In cmd/compile/internal/walk/complit.go, oaslit() decomposes composite literal assignments into field-by-field stores, but guards this optimization with isSimpleName(n.X) which requires the LHS to be an ir.ONAME node that is OnStack(). This rejects all non-local-variable targets:
s[i] = S{...} — slice index (OINDEX)
globalVar = S{...} — global (ONAME but not OnStack())
*ptr = S{...} — pointer dereference (ODEREF)
ptr.Field = S{...} — struct field through pointer (ODOT / OXDOT)
When oaslit returns false, walkCompLit creates a stack temporary via typecheck.TempAt, and the assignment becomes temp = S{...}; dst = temp — with the first part decomposed (since temp is a simple name) and the second being a full struct copy.
Proposed solution
Extend oaslit with a second path for addressable non-name targets: take the address of the destination once (evaluating any index/bounds check exactly once), then pass the dereferenced pointer to anylit which decomposes the literal into field-by-field stores through that pointer.
Safety is ensured by checking that all RHS values are constants, nil, or stack-local non-addrtaken names — these cannot alias with any addressable destination, so writing field-by-field is safe.
For partially-initialized literals (e.g., s[i] = S{A: a}), anylit zeroes the destination in-place before filling the specified fields. This still saves the bulk copy — the temp path would zero a temp + copy the temp, while the direct path just zeroes the destination.
I have a working implementation with benchmarks and codegen tests that shows:
│ baseline │ optimized │
│ sec/op │ sec/op vs base │
StructLitAssign/SliceLiteral 8.91n ± 1% 4.57n ± 2% -48.75% (p=0.000 n=8)
StructLitAssign/Global 8.04n ± 1% 3.97n ± 1% -50.55% (p=0.000 n=8)
StructLitAssign/PtrDeref 8.22n ± 0% 3.84n ± 1% -53.37% (p=0.000 n=8)
StructLitAssign/Nested 3.99n ± 1% 2.93n ± 2% -26.62% (p=0.000 n=8)
StructLitAssign/Embedded 4.01n ± 1% 2.91n ± 1% -27.32% (p=0.000 n=8)
StructLitAssign/FuncCallRHS 9.17n ± 1% 5.62n ± 1% -38.75% (p=0.000 n=8)
StructLitAssign/PartialHalf 5.79n ± 1% 4.12n ± 1% -28.74% (p=0.000 n=8)
Cases where the RHS values may alias the destination (e.g., a global variable on the RHS) correctly fall back to the original path with no regression.
Happy to send a CL.
Go version
go version devel go1.27-5d6aa23e5b (tip)
Also reproducible on go1.24.6 and earlier — this has been the behavior since the optimization was first introduced.
What operating system and processor architecture are you using?
darwin/arm64 (Apple M1 Max). Also affects amd64 and other architectures.
What did you do?
A common pattern in Go services is to populate a struct and store it through a pointer, into a slice, or into a global:
These two functions are semantically equivalent. Compiled with
go build -gcflags='-S' repro.go.What did you expect to see?
Both functions should generate similar code — direct field stores to the destination.
For a local variable,
oaslitinwalk/complit.goalready decomposesx := S{A: a, B: b}intox.A = a; x.B = b(avoiding a temporary). The same decomposition should apply when the LHS is any addressable target.What did you see?
storeUserWithStructallocates a large stack frame with a stack temporary for the entire struct. It zeroes the temporary, fills each field into the temporary, then does a singlewbMove(bulk copy with write barrier) to copy the entire struct to*ptr:Meanwhile
storeUserFieldByFieldstores directly to*ptrwith individual write barriers — no temporary, no bulk copy:The struct literal form is ~2x slower than the field-by-field form in benchmarks. On an 8-field struct:
The same gap applies to all non-local addressable LHS targets (globals, pointer deref, struct fields through pointer).
Root cause
In
cmd/compile/internal/walk/complit.go,oaslit()decomposes composite literal assignments into field-by-field stores, but guards this optimization withisSimpleName(n.X)which requires the LHS to be anir.ONAMEnode that isOnStack(). This rejects all non-local-variable targets:s[i] = S{...}— slice index (OINDEX)globalVar = S{...}— global (ONAMEbut notOnStack())*ptr = S{...}— pointer dereference (ODEREF)ptr.Field = S{...}— struct field through pointer (ODOT/OXDOT)When
oaslitreturns false,walkCompLitcreates a stack temporary viatypecheck.TempAt, and the assignment becomestemp = S{...}; dst = temp— with the first part decomposed (sincetempis a simple name) and the second being a full struct copy.Proposed solution
Extend
oaslitwith a second path for addressable non-name targets: take the address of the destination once (evaluating any index/bounds check exactly once), then pass the dereferenced pointer toanylitwhich decomposes the literal into field-by-field stores through that pointer.Safety is ensured by checking that all RHS values are constants, nil, or stack-local non-addrtaken names — these cannot alias with any addressable destination, so writing field-by-field is safe.
For partially-initialized literals (e.g.,
s[i] = S{A: a}),anylitzeroes the destination in-place before filling the specified fields. This still saves the bulk copy — the temp path would zero a temp + copy the temp, while the direct path just zeroes the destination.I have a working implementation with benchmarks and codegen tests that shows:
Cases where the RHS values may alias the destination (e.g., a global variable on the RHS) correctly fall back to the original path with no regression.
Happy to send a CL.