Skip to content

cmd/compile: missed devirtualization of generic dictionary calls for closed/package-internal constraints #81377

Description

@ugorji

Go version

go version go1.26.7-X:nodwarf5 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='/usr/sbin/gccgo'
GO111MODULE='on'
GOAMD64='v1'
GOARCH='amd64'
GOAUTH='netrc'
GOBIN='/home/ugorji/.local/bin'
GOCACHE='/home/ugorji/.cache/go-build'
GOCACHEPROG=''
GODEBUG=''
GOENV='/home/ugorji/.config/go/env'
GOEXE=''
GOEXPERIMENT='nodwarf5'
GOFIPS140='off'
GOFLAGS=''
GOGCCFLAGS='-fPIC -m64 -pthread -Wl,--no-gc-sections -fmessage-length=0 -ffile-prefix-map=/tmp/go-build2991509374=/tmp/go-build -gno-record-gcc-switches'
GOHOSTARCH='amd64'
GOHOSTOS='linux'
GOINSECURE=''
GOMOD='/home/ugorji/repos/scratch/go.mod'
GOMODCACHE='/home/ugorji/.local/share/go/pkg/mod'
GONOPROXY=''
GONOSUMDB=''
GOOS='linux'
GOPATH='/home/ugorji/.local/share/go'
GOPRIVATE=''
GOPROXY='https://proxy.golang.org,direct'
GOROOT='/usr/lib/golang'
GOSUMDB='sum.golang.org'
GOTELEMETRY='local'
GOTELEMETRYDIR='/home/ugorji/.config/go/telemetry'
GOTMPDIR=''
GOTOOLCHAIN='local'
GOTOOLDIR='/usr/lib/golang/pkg/tool/linux_amd64'
GOVCS=''
GOVERSION='go1.26.7-X:nodwarf5'
GOWORK='/home/ugorji/repos/go.work'
PKG_CONFIG='pkg-config'

What did you do?

Compiled a package containing generic types parameterized over an unexported interface, instantiated solely with unexported concrete implementations where the generic method exceeds the inlining threshold.

Reproducer artifacts:

  • repro.go: minimal reproducer
  • repro.sh: reproducer script (run as ./repro.sh > repro.out)
  • repro.out: output from running repro.sh

repro.sh:

go build -o repro .
go tool nm -size repro | grep "helperDecDriver.*decodeLarge"
go tool objdump -S -s "helperDecDriver.*decodeLarge" repro

repro.go:

package main

import "fmt"

type bytesDecReader struct{ data []byte }

func (r *bytesDecReader) readByte() byte {
	if len(r.data) == 0 {
		return 0
	}
	b := r.data[0]
	r.data = r.data[1:]
	return b
}

type ioDecReader struct{ data []byte }

func (r *ioDecReader) readByte() byte {
	if len(r.data) == 0 {
		return 0
	}
	b := r.data[0]
	r.data = r.data[1:]
	return b
}

type decReader interface {
	*bytesDecReader | *ioDecReader
	readByte() byte
}

type msgpackDecDriver[R decReader] struct{ r R }

func (d *msgpackDecDriver[R]) read() byte { return d.r.readByte() }

type jsonDecDriver[R decReader] struct{ r R }

func (d *jsonDecDriver[R]) read() byte { return d.r.readByte() }

type decDriver interface {
	*msgpackDecDriver[*bytesDecReader] |
		*msgpackDecDriver[*ioDecReader] |
		*jsonDecDriver[*bytesDecReader] |
		*jsonDecDriver[*ioDecReader]
	read() byte
}

type helperDecDriver[D decDriver] struct{}

//go:noinline
func (h *helperDecDriver[D]) decodeLarge(driver D) byte {
	// Represents large method (> 80 AST nodes, e.g. large type switch)
	// var acc byte
	// for i := 0; i < 100; i++ {
	// 	acc += driver.read()
	// }
	// return acc
	c := driver.read()
	c++
	return c
}

func main() {
	h1 := &helperDecDriver[*msgpackDecDriver[*bytesDecReader]]{}
	h2 := &helperDecDriver[*msgpackDecDriver[*ioDecReader]]{}
	h3 := &helperDecDriver[*jsonDecDriver[*bytesDecReader]]{}
	h4 := &helperDecDriver[*jsonDecDriver[*ioDecReader]]{}

	fmt.Println(
		h1.decodeLarge(&msgpackDecDriver[*bytesDecReader]{}),
		h2.decodeLarge(&msgpackDecDriver[*ioDecReader]{}),
		h3.decodeLarge(&jsonDecDriver[*bytesDecReader]{}),
		h4.decodeLarge(&jsonDecDriver[*ioDecReader]{}),
	)
}

What did you see happen?

What did you see happen?
Full stencil duplication: The compiler emits distinct .text symbol instances for each shape permutation (e.g., main.(*helperDecDriver[go.shape.*main.msgpackDecDriver[*main.bytesDecReader]]).decodeLarge(SB), etc.).

Indirect dictionary dispatch remains: In the disassembled stencils, invocations on driver.read(...) are not direct static calls; they remain indirect calls loaded via the dictionary context register.

objdump confirms indirect call via dictionary context:

  0x49e44e    488b33    MOVQ 0(BX), SI   ; Load concrete method ptr from dictionary
  0x49e451    4889c8    MOVQ CX, AX      ; Pass receiver in AX (ABIInternal)
  0x49e454    4889da    MOVQ BX, DX      ; Pass dictionary in context register DX
  0x49e457    ffd6      CALL SI          ; Indirect call (not static devirtualized)

In real-world libraries with large generic driver methods (e.g., a 25 KB decoding method), this duplicates hundreds of kilobytes of code across distinct driver/reader permutations without delivering the performance benefits of monomorphization. I am currently dealing with this in go-codec library, where the generic code binary is even bigger than the monomorphized/generated manually de-duplicated code.

Sample output below:

  49e3e0         70 T main.(*helperDecDriver[go.shape.*main.jsonDecDriver[*main.bytesDecReader]]).decodeLarge
  49e380         70 T main.(*helperDecDriver[go.shape.*main.jsonDecDriver[*main.ioDecReader]]).decodeLarge
  49e4a0         70 T main.(*helperDecDriver[go.shape.*main.msgpackDecDriver[*main.bytesDecReader]]).decodeLarge
  49e440         70 T main.(*helperDecDriver[go.shape.*main.msgpackDecDriver[*main.ioDecReader]]).decodeLarge
TEXT main.(*helperDecDriver[go.shape.*main.jsonDecDriver[*main.ioDecReader]]).decodeLarge(SB) /home/ugorji/repos/scratch/xx/repro.go
func (h *helperDecDriver[D]) decodeLarge(driver D) byte {
  0x49e380		493b6610		CMPQ SP, 0x10(R14)	
  0x49e384		761b			JBE 0x49e3a1		
  0x49e386		55			PUSHQ BP		
  0x49e387		4889e5			MOVQ SP, BP		
  0x49e38a		4883ec08		SUBQ $0x8, SP		
	c := driver.read()
  0x49e38e		488b33			MOVQ 0(BX), SI		
  0x49e391		4889c8			MOVQ CX, AX		
  0x49e394		4889da			MOVQ BX, DX		
  0x49e397		ffd6			CALL SI			
	c++
  0x49e399		ffc0			INCL AX			
	return c
  0x49e39b		4883c408		ADDQ $0x8, SP		
  0x49e39f		5d			POPQ BP			
  0x49e3a0		c3			RET			
func (h *helperDecDriver[D]) decodeLarge(driver D) byte {
  0x49e3a1		4889442408		MOVQ AX, 0x8(SP)										
  0x49e3a6		48895c2410		MOVQ BX, 0x10(SP)										
  0x49e3ab		48894c2418		MOVQ CX, 0x18(SP)										
  0x49e3b0		e80bf5fdff		CALL runtime.morestack_noctxt.abi0(SB)								
  0x49e3b5		488b442408		MOVQ 0x8(SP), AX										
  0x49e3ba		488b5c2410		MOVQ 0x10(SP), BX										
  0x49e3bf		488b4c2418		MOVQ 0x18(SP), CX										
  0x49e3c4		ebba			JMP main.(*helperDecDriver[go.shape.*main.jsonDecDriver[*main.ioDecReader]]).decodeLarge(SB)	

TEXT main.(*helperDecDriver[go.shape.*main.jsonDecDriver[*main.bytesDecReader]]).decodeLarge(SB) /home/ugorji/repos/scratch/xx/repro.go
func (h *helperDecDriver[D]) decodeLarge(driver D) byte {
  0x49e3e0		493b6610		CMPQ SP, 0x10(R14)	
  0x49e3e4		761b			JBE 0x49e401		
  0x49e3e6		55			PUSHQ BP		
  0x49e3e7		4889e5			MOVQ SP, BP		
  0x49e3ea		4883ec08		SUBQ $0x8, SP		
	c := driver.read()
  0x49e3ee		488b33			MOVQ 0(BX), SI		
  0x49e3f1		4889c8			MOVQ CX, AX		
  0x49e3f4		4889da			MOVQ BX, DX		
  0x49e3f7		ffd6			CALL SI			
	c++
  0x49e3f9		ffc0			INCL AX			
	return c
  0x49e3fb		4883c408		ADDQ $0x8, SP		
  0x49e3ff		5d			POPQ BP			
  0x49e400		c3			RET			
func (h *helperDecDriver[D]) decodeLarge(driver D) byte {
  0x49e401		4889442408		MOVQ AX, 0x8(SP)										
  0x49e406		48895c2410		MOVQ BX, 0x10(SP)										
  0x49e40b		48894c2418		MOVQ CX, 0x18(SP)										
  0x49e410		e8abf4fdff		CALL runtime.morestack_noctxt.abi0(SB)								
  0x49e415		488b442408		MOVQ 0x8(SP), AX										
  0x49e41a		488b5c2410		MOVQ 0x10(SP), BX										
  0x49e41f		488b4c2418		MOVQ 0x18(SP), CX										
  0x49e424		ebba			JMP main.(*helperDecDriver[go.shape.*main.jsonDecDriver[*main.bytesDecReader]]).decodeLarge(SB)	

TEXT main.(*helperDecDriver[go.shape.*main.msgpackDecDriver[*main.ioDecReader]]).decodeLarge(SB) /home/ugorji/repos/scratch/xx/repro.go
func (h *helperDecDriver[D]) decodeLarge(driver D) byte {
  0x49e440		493b6610		CMPQ SP, 0x10(R14)	
  0x49e444		761b			JBE 0x49e461		
  0x49e446		55			PUSHQ BP		
  0x49e447		4889e5			MOVQ SP, BP		
  0x49e44a		4883ec08		SUBQ $0x8, SP		
	c := driver.read()
  0x49e44e		488b33			MOVQ 0(BX), SI		
  0x49e451		4889c8			MOVQ CX, AX		
  0x49e454		4889da			MOVQ BX, DX		
  0x49e457		ffd6			CALL SI			
	c++
  0x49e459		ffc0			INCL AX			
	return c
  0x49e45b		4883c408		ADDQ $0x8, SP		
  0x49e45f		5d			POPQ BP			
  0x49e460		c3			RET			
func (h *helperDecDriver[D]) decodeLarge(driver D) byte {
  0x49e461		4889442408		MOVQ AX, 0x8(SP)										
  0x49e466		48895c2410		MOVQ BX, 0x10(SP)										
  0x49e46b		48894c2418		MOVQ CX, 0x18(SP)										
  0x49e470		e84bf4fdff		CALL runtime.morestack_noctxt.abi0(SB)								
  0x49e475		488b442408		MOVQ 0x8(SP), AX										
  0x49e47a		488b5c2410		MOVQ 0x10(SP), BX										
  0x49e47f		488b4c2418		MOVQ 0x18(SP), CX										
  0x49e484		ebba			JMP main.(*helperDecDriver[go.shape.*main.msgpackDecDriver[*main.ioDecReader]]).decodeLarge(SB)	

TEXT main.(*helperDecDriver[go.shape.*main.msgpackDecDriver[*main.bytesDecReader]]).decodeLarge(SB) /home/ugorji/repos/scratch/xx/repro.go
func (h *helperDecDriver[D]) decodeLarge(driver D) byte {
  0x49e4a0		493b6610		CMPQ SP, 0x10(R14)	
  0x49e4a4		761b			JBE 0x49e4c1		
  0x49e4a6		55			PUSHQ BP		
  0x49e4a7		4889e5			MOVQ SP, BP		
  0x49e4aa		4883ec08		SUBQ $0x8, SP		
	c := driver.read()
  0x49e4ae		488b33			MOVQ 0(BX), SI		
  0x49e4b1		4889c8			MOVQ CX, AX		
  0x49e4b4		4889da			MOVQ BX, DX		
  0x49e4b7		ffd6			CALL SI			
	c++
  0x49e4b9		ffc0			INCL AX			
	return c
  0x49e4bb		4883c408		ADDQ $0x8, SP		
  0x49e4bf		5d			POPQ BP			
  0x49e4c0		c3			RET			
func (h *helperDecDriver[D]) decodeLarge(driver D) byte {
  0x49e4c1		4889442408		MOVQ AX, 0x8(SP)											
  0x49e4c6		48895c2410		MOVQ BX, 0x10(SP)											
  0x49e4cb		48894c2418		MOVQ CX, 0x18(SP)											
  0x49e4d0		e8ebf3fdff		CALL runtime.morestack_noctxt.abi0(SB)									
  0x49e4d5		488b442408		MOVQ 0x8(SP), AX											
  0x49e4da		488b5c2410		MOVQ 0x10(SP), BX											
  0x49e4df		488b4c2418		MOVQ 0x18(SP), CX											
  0x49e4e4		ebba			JMP main.(*helperDecDriver[go.shape.*main.msgpackDecDriver[*main.bytesDecReader]]).decodeLarge(SB)	

What did you expect to see?

Because:

  1. The interface constraints (decReader, decDriver) are unexported and cannot be implemented outside this package.
  2. The implementing types (bytesDecReader, ioDecReader, msgpackDecDriver, jsonDecDriver) are unexported and cannot be instantiated by external packages.
  3. The method set is statically known and closed within the package, and explicitly defined in a type constraint

When cmd/compile/internal/noder/stencil.go instantiates these dedicated stencils, it has full compile-time knowledge of the target concrete method.

The compiler should devirtualize the dictionary lookup into a direct static CALL to the concrete method symbol (e.g., (*msgpackDecDriver[*bytesDecReader]).read). This would eliminate indirect call branch mispredictions and allow downstream SSA optimizations without violating shape-sharing invariants or package boundaries.

This is a very tight optimization boundary (completely within package boundary and explicitly limited via unexported constraints containing limited set of unexported generic types). Consequently, I hope it is allowed to go forward.

Activity

  1. added this to the Unplanned milestone on Sep 7, 2026
  2. added theissue type on Sep 7, 2026
  3. JunyangShao commented on Sep 11, 2026

    @JunyangShao
    Contributor

    @golang/compiler

  4. added
    NeedsInvestigationSomeone must examine and confirm this is a valid issue and not a duplicate of an existing one.
    on Sep 11, 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.

    Projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions