Skip to content

testing: implement b.Loop keepalive without preventing inlining, which can lead to heap allocs #73137

Closed

Description

@thepudds

Go version

go version go1.24.2

What did you do?

I read the current b.Loop API doc and draft b.Loop blog. I also used gopls.

What did you see happen?

Currently, the API docs and the draft b.Loop blog seem to recommend b.Loop as a better approach than the older approach, but seem to do so without mentioning some of the potential drawbacks. gopls seems to also recommend switching over all benchmarks as part of its modernizer work.

I'm also seeing people on places like BlueSky and elsewhere who seem to recommend universally converting all benchmarks over to b.Loop, with other people then agreeing with that recommendation without discussion of pros/cons.

What did you expect to see?

Background

As I understand it, the current implementation stops the inlining of any functions called within the b.Loop for loop, which I think means arguments to those functions need to be treated as function parameters within the non-inlined function (and hence can't be constant folded away, etc.), and similarly the return values within the non-inlined function can't be entirely optimized away (so whatever the function does to generate the return values also can't be optimized away).

However, it seems plausible that it might be desirable to change the exact implementation in the future. For example, if #61179 is accepted, it might be desirable to use testing.Keep in the implementation of b.Loop, or even if not directly used in the implementation, there's at least some chance it might make sense to describe b.Loop in terms of testing.Keep semantics.

Or maybe that's not what ends up happening, but it could be worthwhile to keep that door open.

Adjusting the current documentation and draft go.dev blog post

The current API doc says:

The compiler never optimizes away calls to functions within the body of a "for b.Loop() { ... }" loop. This prevents surprises that can otherwise occur if the compiler determines that the result of a benchmarked function is unused.

I wonder if it might make sense to insert the word "currently" or hint that it might change in the future, or maybe imply that the compiler won't optimize away the work of the function, or otherwise tweak the language slightly. The 1.24 release notes use different language that seems better to me:

Function call parameters and results are kept alive, preventing the compiler from fully optimizing away the loop body.

That said, maybe the current API text is ambiguous enough, including it's not 100% clear to me if that text is intending to specifically describe inlining. (For example, if I was talking to someone and wanted to point to a function that I think would be inlined, and I don't think I would say "the compiler will optimize away calls to that function").

Changing the implementation to allow inlining of functions being benchmarked

Separately, while I think b.Loop is an excellent step forward, I am hoping the implementation does change a bit. For me personally, I have not yet embraced b.Loop for new or existing benchmarks because of its current implementation.

For example, in many cases, I'm relying on inlining to happen so that something can be stack allocated.

To pick a trivial but concrete case, suppose you have something like:

// Sample function that relies on mid-stack inlining so that the slice is not always heap allocated.
// (See for example https://words.filippo.io/efficient-go-apis-with-the-inliner).
func NewX(x int) []byte {
	out := make([]byte, 8)
	return newX(out, x)
}

//go:noinline
func newX(out []byte, x int) []byte {
	binary.LittleEndian.PutUint64(out, uint64(x))
	return out
}

Using the old b.N approach, the benchmark avoids the heap allocation, just as normal usage in non-test code would avoid the heap allocation:

	b.Run("b.N", func(b *testing.B) {
		for i := 0; i < b.N; i++ {
			// 0 heap allocs in benchmark.
			// Example of old sink-based approach.
			out := NewX(42)
			sink = out[0]
		}
	})

In contrast, just directly using b.Loop causes a heap allocation, which does not reflect what would happen in normal non-test usage:

	b.Run("b.Loop-basic", func(b *testing.B) {
		for b.Loop() {
			// 1 heap alloc in benchmark because b.Loop purposefully prevents NewX from being inlined.
			// This seems to be recommended approach according to current docs and draft blog.
			NewX(42)
		}
	})

I can for example move the function-under-test to a closure:

	b.Run("b.Loop-closure", func(b *testing.B) {
		bench := func(x int) {
			// 0 heap allocs in benchmark.
			// Closure is not inlined in b.Loop, but no use of return value,
			// so in theory could get "overly" optimized.
			NewX(x)
		}
		for b.Loop() {
			bench(42)
		}
	})

But if I just do that, I might open myself up to some of the problems testing.Keep and b.Loop are intended to avoid, so I can use an older sink style approach with the closure:

	b.Run("b.Loop-closure-with-sink", func(b *testing.B) {
		bench := func(x int) {
			// 0 heap allocs in benchmark.
			// Closure with old sink-based approach for the result.
			out := NewX(x)
			sink = out[0]
		}
		for b.Loop() {
			bench(42)
		}
	})

Or use runtime.KeepAlive as a stand-in for testing.Keep:

	b.Run("b.Loop-closure-with-keepalive", func(b *testing.B) {
		bench := func(x int) {
			// 0 heap allocs in benchmark.
			// KeepAlive requires the result of NewX to be computed, I think, even if NewX is inlined.
			runtime.KeepAlive(NewX(x))
		}
		for b.Loop() {
			bench(42)
		}
	})

All of which is to say:

  1. It might be worthwhile to mention somewhere that the older b.N can still be useful in some cases, or alternatively maybe mention some of the drawbacks and workarounds for the current b.Loop implementation.

  2. It would be nice for the API docs to at least keep the door open to future adjustments, or roughly equivalently, for someone on the core Go team to say publicly that the current language does not really tie our hands for future possible improvements. (Or maybe that statement has effectively already been made).


As an example alternate implementation, I wonder if the compiler could take what I wrote as:

		for b.Loop() {
			NewX(42)
		}

and automatically transform it to the "b.Loop-closure-with-keepalive" example just above. In that case, the bench closure would not be inlined inside the b.Loop block, but NewX could be inlined inside bench.

(I haven't thought through the implications entirely, but perhaps that would retain roughly all the good aspects of the current approach, including not being an overly complex implementation within cmd/compile, but without forcing extra heap allocations. There would still be a function call overhead introduced by b.Loop compared to the older b.N approach, but that is less dramatic impact and also would happen more uniformly, whereas currently b.Loop can introduce a lot of overhead or a little overhead depending on the functions under test.)

Runnable playground link for above examples: https://go.dev/play/p/1bZGIS9Tcxw

CC @JunyangShao, @aclements, @zeebo

Activity

  1. thepudds commented on Apr 2, 2025

    @thepudds
    MemberAuthor

    Also, the problems of heap allocations and the possible workarounds of the user creating manually a closure was discussed a bit in #61179 and #61515, though I think those parts of the discussions might have been prior to the definition & selection of the final semantics of b.Loop for Go 1.24.

    The main point in raising this issue is that I don't see anything about the drawbacks or alternatives in the official docs or blog post. (That said, maybe I missed it or just misunderstood, or a reasonable conclusion might be that this is too low level of a concern).

  2. added
    ToolProposalIssues describing a requested change to a Go tool or command-line program.
    on Apr 2, 2025
  3. aclements commented on Apr 2, 2025

    @aclements
    Member

    Thanks for the detailed report. I hadn't considered that implementing the "Function call parameters and results are kept alive" requirement as disabling inlining would have this particular effect (though now that you point it out, I should have!). I agree that we don't want to pin ourselves down to "nothing gets inlined in a b.Loop loop".

    IMO, we should take two actions here:

    1. Change the doc comment of b.Loop to say what the release notes say: "Function call parameters and results are kept alive, preventing the compiler from fully optimizing away the loop body." We can mention that "Currently, this is implemented by disabling inlining of functions called in a b.Loop loop."
    2. We should fix the compiler implementation so it doesn't have this drawback. I think this would involve an AST walk to mark function arguments and results prior to inlining, allowing inlining as usual, and then ensuring that marked arguments and results are not dead-coded.

    The intent is that b.Loop should be strictly better than b.N, so if there are drawbacks we should just fix them. :)

    cc @prattmic

  4. changed the title [-]testing: consider loosening description of how b.Loop works and maybe mention drawbacks or alternatives[/-] [+]testing: loosen description of how b.Loop works today and ideally avoid forcing heap allocs in future[/+] on Apr 2, 2025
  5. thepudds commented on Apr 2, 2025

    @thepudds
    MemberAuthor

    The intent is that b.Loop should be strictly better than b.N, so if there are drawbacks we should just fix them. :)

    An excellent philosophy. 👍 🚀

  6. gopherbot commented on Apr 2, 2025

    @gopherbot
    Contributor

    Change https://go.dev/cl/662356 mentions this issue: testing: clarify how B.Loop avoids optimizing away all the useful work

  7. AlekSi commented on Apr 3, 2025

    @AlekSi
    Contributor

    Should this work also cover testing.PB.Next?

  8. added
    NeedsInvestigationSomeone must examine and confirm this is a valid issue and not a duplicate of an existing one.
    on Apr 3, 2025
  9. changed the title [-]testing: loosen description of how b.Loop works today and ideally avoid forcing heap allocs in future[/-] [+]testing: implement b.Loop keepalive without preventing inlining, which can lead to heap allocs[/+] on Apr 4, 2025
  10. thepudds commented on Apr 18, 2025

    @thepudds
    MemberAuthor

    FWIW, #73417 is probably a case of "no inlining" affecting benchmark results in a way people found surprising.

    In that issue, someone reported a bug against generics support in the compiler, citing ~40 or so b.Loop benchmarks that had a geomean +41.19% change based on introducing generics to some code.

    A later comment there then shows the b.N form with a much smaller geomean of +0.87% (which might just be noise), and the bug was closed shortly after that.

    Inlining most likely played a part, including because the change being benchmarked seemed to include introducing some ~1 line wrapper functions like:

    func DecodeRuneInString(s string) (r rune, size int) {
            return genDecodeRune(s)
    }

    (I didn't run the benchmarks myself or poke too deeply, but seems plausible it is related to this b.Loop issue here).

  11. 13 remaining items

  12. added a commit that references this issue on Nov 21, 2025
    22f24f9
  13. cespare commented on Nov 21, 2025

    @cespare
    Contributor

    @JunyangShao thanks very much for your work on this (plus everyone else who pitched in)!

  14. gopherbot commented on Jan 22, 2026

    @gopherbot
    Contributor

    Change https://go.dev/cl/738600 mentions this issue: _content/doc/go1.26: document testing.B.Loop change

  15. thepudds commented on Jan 24, 2026

    @thepudds
    MemberAuthor

    I wanted to understand the new 1.26 b.Loop behavior better, so I poked around a bit at how it's implemented.

    One thing I noticed is it seems the new behavior means b.Loop still seems to cause extra allocations compared to the older b.N style benchmarks, including in the example I built for this issue here -- in particular, my "b.Loop-basic" benchmark from the playground link in the opening comment above.

    The function being benchmark now can be inlined in Go 1.26, which is an improvement compared to Go 1.24/1.25 behavior, but the undesirable allocation still happens with b.Loop in go1.26rc2 (compared to the allocations do not happen in an otherwise equivalent b.N benchmark).

    I suspect that is due to the way the 1.26 b.Loop compiler changes are handling the temporary variables it is creating. It looks like the autotmp variables are being declared by the compiler outside the loop body, which escape analysis will determine is an escaping value and results in a heap allocation.

    I sent WIP https://go.dev/cl/738822 with a candidate fix.

    I'll also create a proper issue to help with tracking/discussion (probably later this weekend).

    Finally, I'm still poking at it a bit, so sorry if I've made a mistake in the diagnosis here.

  16. gopherbot commented on Jan 24, 2026

    @gopherbot
    Contributor

    Change https://go.dev/cl/738822 mentions this issue: cmd/compile/internal/bloop: avoid heap allocation for temp variables in b.Loop body

  17. aclements commented on Jan 28, 2026

    @aclements
    Member

    @thepudds , thanks for following up on this. Sorry that we didn't quite fix the underlying issue!

  18. thepudds commented on Jan 28, 2026

    @thepudds
    MemberAuthor
  19. gopherbot commented on Jan 30, 2026

    @gopherbot
    Contributor

    Change https://go.dev/cl/740460 mentions this issue: [release-branch.go1.26] cmd/compile/internal/bloop: avoid extraneous heap allocs in b.Loop body due to autotmp variable scope

  20. gopherbot commented on Jan 30, 2026

    @gopherbot
    Contributor

    Change https://go.dev/cl/740600 mentions this issue: [release-branch.go1.26] cmd/compile/internal/bloop: avoid extraneous heap allocs in b.Loop body due to autotmp variable scope

  21. removed
    NeedsInvestigationSomeone must examine and confirm this is a valid issue and not a duplicate of an existing one.
    on Jan 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

FixPendingIssues that have a fix which has not yet been reviewed or submitted.PerformanceToolProposalIssues describing a requested change to a Go tool or command-line program.release-blocker

Type

No type

Projects

No projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions