A Go thumbnailing service heap-allocates its pixel buffer on every resize and `go build -gcflags=-m` blames an interface method call. How do you fix it?
answer
- the compiler could not finish a proof
- an unknown callee might keep your slice
- so it must assume the worst
- move the seam out of the loop
- then re-read the escape output
basics
~20 sThe callee behind an interface is invisible to the compiler, so escape analysis assumes it retains the buffer and heap-allocates the backing array. Take the concrete type in the inner function, keep the interface at the package boundary, and re-measure.
solid answer
~50 sFirst reproduce it: a benchmark over one image with allocation reporting on, plus `go build -gcflags=-m` to find the line saying the `[]byte` escapes to the heap. The cause is that an indirect call is opaque — the compiler cannot see what the resizer does with the slice, so it assumes the slice is retained and heap-allocates the backing array every call. The cleanest fix is to stop crossing the interface inside the loop: keep the exported entry point taking the interface and forward into an unexported function over the concrete type, where the callee can be inlined and the buffer stays in the frame. If the seam must stay there, make the buffer stop being per-call instead — have the caller own a reusable destination slice. Then re-read the escape output and re-run the benchmark.
code
go · 19 linestype Resizer interface {
Resize(dst, src []byte, w, h int) []byte
}
type boxResizer struct{ quality int }
func (b boxResizer) Resize(dst, src []byte, w, h int) []byte {
return append(dst[:0], src[:w*h]...)
}
// Exported seam: one indirect call per request.
func Thumbnail(r Resizer, dst, src []byte) []byte {
return r.Resize(dst, src, 64, 64)
}
// Inner loop over the concrete type: inlinable, and dst can stay in the frame.
func thumbnailBox(r boxResizer, dst, src []byte) []byte {
return r.Resize(dst, src, 64, 64)
}go deeper
Know that the compiler decides where a value lives, and that go build -gcflags=-m is how you see whether something went to the heap.
Explain why an argument passed into an interface method must be assumed to escape: the compiler has no callee body to analyse, so it cannot prove the value dies with the frame.
Walk the whole loop — benchmark, escape output, a fix that keeps the seam at the boundary, then re-measure — and be explicit that the same opacity causes both the lost inlining and the allocation.
Decide how far to push this: which measured allocations justify reshaping a signature, and whether the team pays for the win with an API change, a buffer-ownership convention, or a profile-guided build.
## Reading the symptom correctly One allocation per resize, on a service doing thousands of resizes per second, is not a small thing: it is garbage-collector work, cache pressure and tail latency. But before changing any code, you want two artefacts. 1. **A benchmark** over a fixed input that reports allocations per operation. Without it you cannot tell whether the change helped, and you cannot stop it regressing later. 2. **The compiler's own explanation.** `go build -gcflags=-m ./...` prints inlining and escape-analysis decisions, one per source line. You are looking for the line that reports your `[]byte` escaping to the heap, and for the absence of any line saying the resize method was inlined. ## Why an interface call makes a buffer escape Escape analysis is a compile-time proof. To keep a value in the stack frame, the compiler must show no reference to it survives the call. For a **direct** call it does that by analysing the callee: it learns whether that function stores its argument in a global, in a struct that outlives the call, or in a channel. For a call through an interface there is no single callee to analyse. Any type satisfying the interface — including one defined in another package, or in a test — could keep the slice forever. The compiler therefore takes the only sound position available: arguments to the indirect call are assumed to escape. Your `[]byte` header escapes, its backing array is heap-allocated, and it happens once per call. The same opacity is why the resize method was not inlined; the two symptoms have one cause. Note what this is *not*. It is not that slices are always heap-allocated (they are not), and it is not the interface value itself being expensive. It is a proof the compiler could not complete. ## The fixes, in the order to try them **1. Move the interface out of the loop.** This is almost always the right answer and it changes no behaviour. Keep the exported function taking the interface — that is the seam other code depends on — and have it forward immediately into an unexported function that takes the concrete type. All the per-pixel work happens below that line, where the compiler can inline and where escape analysis works again. You pay one indirect call per request instead of one per iteration. **2. Stop creating a new buffer per call.** If the interface genuinely must stay in the loop, attack the allocation from the other side: change the signature so the caller supplies the destination slice and the callee appends into it, or keep a package-level `sync.Pool` of `[]byte` and return the buffer on the way out. This is only safe if the callee does not retain the slice past the call — the same property the compiler could not prove, now asserted by you and enforced by review. Note that `sync.Pool` has its own cost per get and put, and its entries are dropped by the collector, so it earns its place for large buffers and not for small ones. **3. Let a profile-guided build recover the call site.** With a representative CPU profile, the compiler can see that this call site is hot and dominated by one concrete type, emit a type check plus a direct call to that type's method, and then inline it. That can restore the escape information without touching the API at all. It is the option that keeps every seam intact, and it is contingent on the profile staying representative. ## What to avoid - Reaching for `unsafe` to reuse memory the compiler thinks escapes. You are removing the compiler's proof, not satisfying it. - Deleting the interface everywhere on the strength of one measurement, including the boundary where it costs nothing. - Assuming the fix worked. Re-run the benchmark and re-read `go build -gcflags=-m`. If the line now reports the buffer as not escaping and allocations per operation dropped, you are done; if only one of those changed, you moved the allocation rather than removed it. ## Locking it in Leave the benchmark in the package. The refactor that reintroduces the interface into the inner loop will look completely reasonable in review, and the allocation count is the only thing that will notice.
- How do you confirm the fix rather than assume it?Re-run `go build -gcflags=-m` and look for the buffer now reported as not escaping, and re-run the benchmark with allocation reporting: allocations per operation should drop for that path. Keep the benchmark in the package so a later refactor that puts the interface back into the loop shows up as a measurable regression rather than a silent one.
- Why not simply keep the interface and add a sync.Pool of buffers?That is a legitimate answer for genuinely large buffers, but it is heavier: `sync.Pool` costs bookkeeping on every get and put, its entries are dropped by the garbage collector, and it is only correct if the callee provably does not retain the buffer past the call. When the allocation exists only because the compiler lost sight of the callee, removing the indirection from the loop is cheaper and simpler.
- Does keeping the interface at the package boundary still cost you anything?Yes, but at a rate that does not matter: one indirect call and one boxing conversion per call into the package instead of per iteration. The usual shape is an exported function taking the interface that forwards straight into an unexported function over the concrete type, where the loop lives. You keep substitutability and pay for it once.
- Would you consider a profile-guided build here?As the option that changes no API. Given a representative CPU profile, the compiler can devirtualise a hot call site dominated by one concrete type and then inline it, which can restore both the inlining and the escape information. It is contingent on the profile staying representative, so it complements the structural fix rather than replacing it.
saying these in an interview costs you the question
- Blames the garbage collector rather than an escaping argument
- Says slices in Go are always heap-allocated anyway
- Reaches for unsafe to defeat escape analysis
- Deletes every interface in the package after one measurement
- Never re-measures after the change