Consider the following program:

package x

type i interface { f(*int) }

type f func(*int)
func (f f) f(x *int) { f(x) }

//go:noinline
func y1(i i) {
    i.f(nil)
}

//go:noinline
func y2(f func(*int)) {
    f(nil)
}

func z(n int) {
    y1(f(func(y *int) { *y = n }))
    y2(func(y *int) { *y = n })
}

i is a single method interface; f is the classic idiom for converting a func into such an interface, which is completely free, because funcs are stored as direct interface values.

We pass two versions of the same function into functions that call it with nil: once as an interface, and once as a func. One would think these generate identical code. However, escape analysis gets confused and concludes that the argument to y1 escapes, but not the argument to y2, as seen in this assembly listing (go1.24.2):

        TEXT    x.z(SB), ABIInternal, $40-8
        CMPQ    SP, 16(R14)
        JLS     ...
        PUSHQ   BP
        MOVQ    SP, BP
        SUBQ    $32, SP
        MOVQ    AX, 48(SP)
        LEAQ    type:noalg.struct { ... }(SB), AX
        CALL    runtime.newobject(SB)
        LEAQ    x.z.func1(SB), CX
        MOVQ    CX, (AX)
        MOVQ    x.n+48(SP), CX
        MOVQ    CX, 8(AX)
        MOVQ    AX, BX
        LEAQ    go:itab.x.f,x.i(SB), AX
        NOP
        CALL    x.y1(SB)
        LEAQ    x.z.func2(SB), CX
        MOVQ    CX, 16(SP)
        MOVQ    48(SP), CX
        MOVQ    CX, +24(SP)
        LEAQ    16(SP), AX
        CALL    x.y2(SB)
        ADDQ    $32, SP
        POPQ    BP
        RET

The compiler chooses to spill one funcval to the heap but not the other. This is very surprising to me, and either indicates a bug in how escape analysis reasons through interfaces, or something that the current analysis is not smart enough to handle.

In any case, I discovered this because a similar variant of this code appears to cause some inlining budget to be exhausted: in a function that used to do nothing but return a closure, wrapping the closure in an interface conversion causes it to no longer inline. I cannot get that to reproduce and instead discovered this bug while building a POC, which may be the root cause.

Comment From: gabyhelp

Related Issues

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

Comment From: randall77

I agree that when calling a function on an interface, the function object itself does not escape, but its contents might. I'm not sure escape analysis can distinguish that difference. Maybe?

Comment From: mcy

Hmm... well, the thing that makes me think this is a bug is that we can distinguish this case for funcvals, which are essentially equivalent to non-pointer interfaces that are never type-asserted in every way. The caller, who is the one ultimately making the decision to spill to the heap, also knows that this interface doesn't escape its argument.

Comment From: thepudds

Hi @mcy, if you don't mind, I am going to change this issue title back closer to your original title that included the text "...funcs in interfaces..." to better differentiate your example from more general cases of interfaces causing escapes.

FWIW, I have a WIP CL that I think might address this as part of #62653, though that's only based on a quick look, and I would like to look at your example more carefully to confirm. Sample resulting assembly:

  (mcy-example/main.go:18)   TEXT    mcy-example.z(SB), ABIInternal, $40-8
  (mcy-example/main.go:18)   CMPQ    SP, 16(R14)
  [...]
  (mcy-example/main.go:18)   MOVQ    AX, mcy-example.n+48(SP)
  (mcy-example/main.go:18)   PCDATA  $3, $-1
  (mcy-example/main.go:19)   MOVUPS  X15, mcy-example..autotmp_1+16(SP)
  (mcy-example/main.go:19)   LEAQ    mcy-example.z.func1(SB), CX
  (mcy-example/main.go:19)   MOVQ    CX, mcy-example..autotmp_1+16(SP)
  (mcy-example/main.go:19)   MOVQ    AX, mcy-example..autotmp_1+24(SP)
  (mcy-example/main.go:19)   LEAQ    mcy-example..autotmp_1+16(SP), BX
  (mcy-example/main.go:19)   LEAQ    go:itab.mcy-example.f,mcy-example.i(SB), AX
  (mcy-example/main.go:19)   PCDATA  $1, $0
  (mcy-example/main.go:19)   CALL    mcy-example.y1(SB)
  (mcy-example/main.go:20)   MOVUPS  X15, mcy-example..autotmp_1+16(SP)
  (mcy-example/main.go:20)   LEAQ    mcy-example.z.func2(SB), CX
  (mcy-example/main.go:20)   MOVQ    CX, mcy-example..autotmp_1+16(SP)
  (mcy-example/main.go:20)   MOVQ    mcy-example.n+48(SP), CX
  (mcy-example/main.go:20)   MOVQ    CX, mcy-example..autotmp_1+24(SP)
  (mcy-example/main.go:20)   LEAQ    mcy-example..autotmp_1+16(SP), AX
  (mcy-example/main.go:20)   NOP
  (mcy-example/main.go:20)   CALL    mcy-example.y2(SB)
  (mcy-example/main.go:21)   ADDQ    $32, SP
  (mcy-example/main.go:21)   POPQ    BP
  (mcy-example/main.go:21)   RET

Note the runtime.newobject is no longer there in your z function.

My plan is to try to get that work across the finish line for this upcoming dev cycle (so Go 1.26).

I guess I would not call it a bug necessarily, but rather I think it might be that the GA compiler is not able to prove that the function object is not retained or otherwise leaked to the heap. As I said, though, I'd like to look at this more, and maybe I misunderstood or maybe something else is happening.

(Finally, while we are here -- I'll quickly mention I have enjoyed reading your very interesting Go blog posts recently, and also thank you for taking the time to create detailed issues on the tracker when you notice something that can be improved in the current compiler.)

Comment From: mcy

@thepudds Of course. FWIW, I was able to replicate this issue with a pointer-receiver interface that had core type int, and a value-receiver interface that had core type struct {*int}, so I'm not sure if the title change is necessarily correct. Funcs are relevant insofar that they are are similar to interfaces from an escape perspective, and because the original example that helped me find this involved them.

But I don't think that the title is a huge concern for me.