-
Notifications
You must be signed in to change notification settings - Fork 17.9k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
cmd/compile: function signature based optimizations #71628
Comments
Related Issues
(Emoji vote if this was helpful or unhelpful; more detailed feedback welcome in this discussion.) |
Unfortunately this kind of thing really requires whole-program analysis, which we are loth to do (because Go should scale to really large codebases). |
Currently we do backward escape analysis. So let's say all the implementations of |
As Keith said, the compiler can see only the functions in the current package (plus summaries of their transitive dependencies), but it cannot predict what is to come in packages higher up within the build. So, this kind of optimization is limited to a single compilation unit. For example, if an interface method is foo(bar), where both the method name foo and the parameter type name bar are unexported, the compiler can safely assume that all implementations of that method reside in the same package, and simplify accordingly. (Although a type from a higher-level package may also have a foo(bar) method thanks to embedding, the implementation of that method must be defined in the same package as foo.) |
Not sure about that, you can use type inference to hack that is some edge cases: package foo
type bar struct{ a [128]byte }
type iface interface {
foo(*bar)
}
type fooImpl func(*bar) *bar
func (f fooImpl) foo(b *bar) { f(b) }
func Iface(f func(b *bar) *bar) {
var iface iface = fooImpl(f)
iface.foo(&bar{})
} package main
import (
"aa/foo"
"runtime"
)
func Val[T any](t T) T {
go func() {
runtime.KeepAlive(t)
}()
return t
}
func main() {
foo.Iface(Val)
} |
Yeah, I realized just after hitting send that the my mention of the parameter type bar was a distracting irrelevance because you don't need to name a type to create values of it: the name of the method is all that matters and all that the compiler can count on. In any case, in your example, the call BTW I'm not sure what |
My idea was: In package |
A Go compiler is free to optimize away the goroutine entirely since it has no dependable consequence. Even assuming Val's parameter does escape, I don't think it changes anything: the key point is that iface.foo is a reference to the abstract method foo, whose concrete implementation's source (even if generic) must reside in the same package. |
This is now offtopic, but shouldn't
Lines 568 to 575 in a704d39
|
It does, in sequential code, but in your example there are no happens-before relationships between the two goroutines, so the effect of the KeepAlive is to ensure only that parameter t is live at some unspecified moment in the future. The compiler is free to assume that moment is in the past by the time In any case I don't see how the question of t's escape matters. |
It doesn't. This can be runtime-supported and work with plugins by including escape-analysis bits in function pointers and interfaces. Any time a function pointer or interface is constructed, associate up to two bits for each value that could escape and then query these bits at runtime to determine whether to allocate on the stack, a goroutine-local heap (ie. an arena) or the program heap. This won't catch everything but could still provide significant performance improvements. Heap allocations are usually the biggest cost in hot functions. In some cases the compiler may be able to forward escape bits in trivial function/interface wrappers, ie. the following type MyReader struct {
io.Reader // escape bits for Read are forwarded from this value.
}
func (my MyReader) Read(buf []byte) (int, error) {
// do something that doesn't cause buf to escape
return my.Reader.Read(buf)
} |
How does this compose in the large? Static escape analysis composes by induction, so for a chain of static calls f -> g -> h you can prove things about f based on h. But I don't see how a dynamic scheme can scale, because the information about whether h will escape its argument doesn't exist at the time you call f, forcing the caller of f to be conservative. Has this approach been implemented before in a production allocator? Has it been written up? |
I believe you are referring to dynamic cases, such that whether If it cannot be determined whether or not the Most function/interface wrappers (think
I'm not aware of any implementation or papers on this, I do think this approach is worth considering, as it's the only known alternative that I'm aware of if you don't want to do full-program analysis at compile time. As far as an implementation suggestions go, the easiest approach to start from would probably be: For each method/function that has any pointer-like arguments, generate additional method/function that can be used to query escape information. type MyDiscardWriter struct{}
func (MyDiscardWriter) Write(buf []byte) (int, error) { return len(buf), nil }
func (MyDiscardWriter) noescape_Write(arg_index int) bool { return true } // no argument escapes, so just returns true
type MyReader struct{ io.Reader }
func (my MyReader) Read(buf []byte) (int, error) { return my.Reader.Read(buf) }
func (my MyReader) noescape_Read(arg_index int) (int, error) { return my.Reader.noescape_Read(arg_index) } Then use the result of any dependant There could still be room for improvements but it would probably be a good place to start from. |
It is widely known that passing anything to an interface method call with arguments that contain pointers causes heap allocations of that value. This happens because the compiler’s escape analysis cannot prove that the call would not cause the values to be escaped. Even though we don't know exactly which function is going to be called, there is one thing that we know at the caller site: the signature of the function being called. Using that fact, the compiler could collect all functions with the same function signature, run escape analysis over them and then use the per-signature result to decide whether dynamic dispatch calls need to have the arguments escaped. You can think of this as a fallback. Currently the fallback is to heap allocate, with this approach, if none of the functions with the same signature cause their arguments to escape, then dynamic dispatch calls would not require heap allocation either.
If this was implemented in the compiler I would expect a reduced heap allocations count in most programs, some examples:
io.Reader
/io.Writer
would likely not need to be allocated on the heap.http.Handler
to be stack allocated (they are often wrapped, especially thehttp.ResponseWriter
).log/slog
would not cause allocations, and the optimization inslog.Record
would not be really needed anymore:go/src/log/slog/logger.go
Lines 271 to 272 in 215de81
go/src/log/slog/record.go
Lines 38 to 50 in 215de81
It is also worth noting that
log/slog
API would likely not need theEnabled()
method if this kind of optimization was present:go/src/log/slog/handler.go
Lines 31 to 41 in 215de81
sync.Pool
(proposal: net/http: add Request.CopyTo #68501)Obviously, this would not work in all cases and has some drawbacks, it only takes one function to force heap allocation of other dynamic dispatch calls (of functions with same signature). This might easily prevent signatures based solely on basic types from benefiting from this optimization, say:
func(b []byte) { go func ( /* do sth with b */ }
, so folks might pollute functions with some unused and unneeded parameters just to make this optimization work:func(b []byte, _ ...struct{})
. This optimization would be beneficial for signatures that take/return custom (package-local) types, likelog/slog
sHandle(context.Context, slog.Record) error
.This is just an idea that came to my mind while reading #62653. I'm not even sure whether this change is rational, because changes in the escape analysis results would most likely require the dependencies to be recompiled, changes to the cache and probably some other stuff that I am not even aware of.
The no-copy string -> []byte optimization could also take benefit from this kind of optimization.
CC @golang/compiler
The text was updated successfully, but these errors were encountered: