Location>code7788 >text

Why you shouldn't be overly concerned about go language escape analysis

Popularity:823 ℃/2024-10-21 10:13:43

Escape analysis is considered one of the features of the go language, the compiler automatically analyzes whether the variable/memory should be allocated on the stack or on the heap, the programmer does not need to actively care about these things, to ensure the safety of the memory and at the same time reduce the burden of the programmer.

However, this "burden-reducing" feature has now become a mental burden for programmers. Especially after the popularization of the eight-volume essay, questions related to escape analysis appear more and more frequently in interviews, and not knowing it often means losing a job opportunity, and some people even think that not knowing escape analysis is the same as not knowing how to go.

I don't like these phenomena, not because I don't know how to go, but I know what the situation of escape analysis is: there are version differences in the analysis rules, the rules are overly conservative and many times escape variables that can be on the stack to the heap, the rules are complicated and lead to a lot of correlation cases, and so on. Not to mention the fact that some of the lesser-quality 8-streams are misleading in their descriptions of escape analysis.

So I would suggest that most people go back to the original intent of escape analysis - it should be like transparent for programmers and not be overly concerned about it.

How do you know if a variable has escaped?

I've also seen something more over the top than memorizing outdated eight-letter words: a group of people arguing around a bare piece of code about whether or not a variable will escape or not.

They didn't even use the validation methods that come with the go compiler to argue their point.

There is no point in arguing like that, you should check the results of the compiler escape analysis with the following command:

$ go build -gcflags=-m=2 

# command-line-arguments
./:5:6: cannot inline main: function too complex: cost 104 exceeds budget 80
./:12:20: inlining call to 
./:12:21: num escapes to heap:
./:12:21:   flow: {storage for ... argument} = &{storage for num}:
./:12:21:     from num (spill) at ./:12:21
./:12:21:     from ... argument (slice-literal-element) at ./:12:20
./:12:21:   flow:  = &{storage for ... argument}:
./:12:21:     from ... argument (spill) at ./:12:20
./:12:21:     from  := ... argument (assign-pair) at ./:12:20
./:12:21:   flow: {heap} = *:
./:12:21:     from (, ...) (call parameter) at ./:12:20
./:7:19: make([]int, 10) does not escape
./:12:20: ... argument does not escape
./:12:21: num escapes to heap

What escaped and what didn't show up clearly-escapes to heapindicates that the variable or expression escaped.does not escapeThen it means that no escape has occurred.

In addition, this article discusses the official go gc compiler, like some third-party compilers such as tinygo is not obliged to use and official exactly the same escape rules - these rules are not part of the standard and do not apply to some special scenarios.

The go version for this article is 1.23, and I don't want someone with a 1.1x or 1.3x compiler to come up to me at some point in the future and ask me why my experiments are different.

Problems in the Octavo

Just to be clear, the willingness to share information is still respectable, for what it's worth.

But at least do some simple verification before you share, or else the inverted cause and effect and gibberish will be more than a laugh.

Things that don't know the size during compilation escape

There's nothing really wrong with that, but a lot of the eight-page articles either end here or give an example of something that doesn't actually escape in a lot of cases and then do a whole bunch of hilarious explanations.

For example:

package main

import "fmt"

type S struct {}

func (*S) String() string { return "hello" }

type Stringer interface {
        String() string
}

func getString(s Stringer) string {
        if s == nil {
                return "<nil>"
        }
        return ()
}

func main() {
        s := &S{}
        str := getString(s)
        (str)
}

Some of the eight writers would saygetString's argument s is hard to know what the actual type is at compile time, so the size is poorly determined, and so can cause arguments passed to it to escape.

Is this correct? Yes and no, because the compilation period is so broad that an interface doesn't know its actual type in the first half of the "compilation period", but it may know it in the second half. So the key is when the escape analysis is performed, which directly determines the escape analysis results for variables of type interface.

Let's verify:

# command-line-arguments
...
./:22:18: inlining call to getString
...
./:22:18: devirtualizing  to *S
...
./:23:21: str escapes to heap:
./:23:21:   flow: {storage for ... argument} = &{storage for str}:
./:23:21:     from str (spill) at ./:23:21
./:23:21:     from ... argument (slice-literal-element) at ./:23:20
./:23:21:   flow:  = &{storage for ... argument}:
./:23:21:     from ... argument (spill) at ./:23:20
./:23:21:     from  := ... argument (assign-pair) at ./:23:20
./:23:21:   flow: {heap} = *:
./:23:21:     from (, ...) (call parameter) at ./:23:20
./:21:14: &S{} does not escape
./:23:20: ... argument does not escape
./:23:21: str escapes to heap

I've only intercepted key information, otherwise there's too much noise.&S{} does not escapeThis sentence tells us directlygetStringThe parameters did not escape.

Why? Because.getStringis inlined, and after inlining the compiler realizes that the actual type of the argument is S, so thedevirtualizing to *SDid the de-virtualization, which now the actual type of the interface is known to the compiler, so there's no need to let the parameters escape.

And str escapes, str's type is known and its content is a constant string, shouldn't it escape according to the theory of the octet? In fact, the information above also tells you why, becauseSome of the internal functions can't be inlined, and they use any to accept arguments, so the compiler can't do de-virtualization to finalize the real size of the variable, so str has to escape. Remember what I said at the beginning, that escape analysis is very conservative, because memory safety and program correctness come first.

The situation is different if you disable functions from being inlined; we can manually disable a function from being inlined in go:

+//go:noinline
func getString(s Stringer) string {
        if s == nil {
                return "<nil>"
        }
        return ()
}

Look at the results this time around:

# command-line-arguments
./:14:6: cannot inline getString: marked go:noinline
...
./:22:14: &S{} escapes to heap:
./:22:14:   flow: s = &{storage for &S{}}:
./:22:14:     from &S{} (spill) at ./:22:14
./:22:14:     from s := &S{} (assign) at ./:22:11
./:22:14:   flow: {heap} = s:
./:22:14:     from s (interface-converted) at ./:23:19
./:22:14:     from getString(s) (call parameter) at ./:23:18
./:22:14: &S{} escapes to heap
./:24:20: ... argument does not escape
./:24:21: str escapes to heap

getStringThere's no way to inline it, so you can't do de-virtualization, and in the end you can't know the size of the variable before the escape analysis, so s as an argument ends up escaping.

Therefore, the expression "compilation period" is not correct, and the correct expression should be "Variables/memory allocations whose exact size is not known at the time of escape analysis execution escape". One more thing to note:Rewriting of inline and some of the built-in functions/statements occurs prior to escape analysis. You should know what an inline is, and rewrite and rewrite to introduce it properly when you have time.

And GO is more casual about what can be calculated before escape analysis:

func main() {
        arr := [4]int{}
        slice := make([]int, 4)
        s1 := make([]int, len(arr)) // not escape
        s2 := make([]int, len(slice)) // escape
}

s1 doesn't escape but s2 does, because len returns a compile-time constant directly when it calculates the length of the array. And len does not compute the length of slice at compile time, so even though it is clear that the length of slice at this point is 4, go still assumes that the size of s2 can't be determined before the escape analysis.

That's why I caution against being overly concerned about this escape analysis stuff, many times it's counter-intuitive.

Doesn't it escape if you know the size during compilation?

Some octogenarians have drawn conclusions such as the following based on the phenomena in the previous section:make([]T, constant)There will be no escape.

I think a competent go or c/c++/rust programmer should immediately and almost instinctively retort: without escaping it will be allocated on the stack, which is usually limited in space (system stacks are usually 8-10M, and goroutines are a fixed 1G), and what if the size of the memory space needed for this make exceeds the upper limit of the stack?

Obviously exceeding the limit escapes to the heap, so the above sentence isn't quite right. go certainly has a limit on the amount of memory that can be allocated on the stack space at one time, and that limit is much smaller than the upper limit on the stack size, but I'm not going to tell you how much it is because there's no guarantee that it won't be changed in the future, and as I said it doesn't really do much good to concern yourself with it.

There's also the classic case where make generates content as a return value:

func f1() []int {
        return make([]int, 64)
}

The escape analysis will give this result:

# command-line-arguments
...
./:6:13: make([]int, 64) escapes to heap:
./:6:13:   flow: ~r0 = &{storage for make([]int, 64)}:
./:6:13:     from make([]int, 64) (spill) at ./:6:13
./:6:13:     from return make([]int, 64) (return) at ./:6:2
./:6:13: make([]int, 64) escapes to heap

This is not much of a surprise, since the return value is going to continue to be used after the function call ends, so it can only be allocated on the heap. This is the original purpose of escape analysis.

But because this function is so simple, it can always be inlined, and once it's inlined, this make is no longer a return value, so the compiler has a chance to keep it from escaping. You can do this with the//go:noinlineTry.

The number of elements in a slice has little to do with whether or not it escapes.

Others will say this: "too many elements in the slice will lead to escape", and some will also swear that this quantity limit is something like 10,000, 100,000.

Well, let's look at an example:

package main

import "fmt"

func main() {
        a := make([]int64, 10001)
        b := make([]byte, 10001)
        (len(a), len(b))
}

Analyze the results:

...
./:6:11: make([]int64, 10001) escapes to heap:
./:6:11:   flow: {heap} = &{storage for make([]int64, 10001)}:
./:6:11:     from make([]int64, 10001) (too large for stack) at ./:6:11
...
./:6:11: make([]int64, 10001) escapes to heap
./:7:11: make([]byte, 10001) does not escape
...

How the number of elements is the same, one escaped and one did not? Explained and the number of elements will not matter, only and the last section of the stack on the memory allocation size limitations, more than will escape, not more than you allocate 100 million elements can be.

The point is that this kind of boring question doesn't come out too often, and my friends and I have encountered this:

make([]int, 10001)

I asked you if this thing escapes, the interviewer probably forgot that the int length is not fixed, it is 4 bytes on a 32-bit system, and 8 bytes on a 64-bit system, so there is no more information before this question can't be answered, and you can only shake your head if you bring Rob Pike to him. If you have an interview, you can still talk to the interviewer, but if you have a written test, what do you do?

This is what I mean by inverse cause and effect, slice and arrays will escape not because of a high number of elements, but because the memory consumed (element size x number) exceeds the upper limit specified.

There is little difference between new and make when it comes to escape analysis

Some octets also say that new objects often escape while make doesn't, so you should use new as little as possible.

It's an old piece of eight, and I don't think anyone would read it now, yet even at the time it was wrong. I think it's probably because the author of the octet grafted knowledge from Java/c++ without validation.

I should clarify that new and make are indeed very different, but only in two places:

  1. new(T)Returns *T, whereasmake(T, ...)Return to T
  2. new(T)T can be of any type (but slice and interfaces are generally not recommended), whereasmake(T, ...)of T can only be slice, map, or chan.

Those are the two, plus they're a little different in the exact way they're initialized for things like slice, but that's barely included in the second point.

So it will never be the case that new is more likely to cause an escape, and new, like make, is only affected by size constraints as well as reachability as to whether it will escape or not.

Look at an example:

package main

import "fmt"

func f(i int) int {
        ret := new(int)
        *ret = 1
        for j := 1; j <= i; j++ {
                *ret *= j
        }
        return *ret
}

func main() {
        num := f(5)
        (num)
}

Results:

./:5:6: can inline f with cost 20 as: func(int) int { ret := new(int); *ret = 1; for loop; return *ret }
...
./:15:10: inlining call to f
./:16:13: inlining call to 
./:6:12: new(int) does not escape
...
./:15:10: new(int) does not escape
./:16:13: ... argument does not escape
./:16:14: num escapes to heap

see thatnew(int) does not escapeIs it done? The rumors are not broken.

However, in order to prevent people from being serious, I have to introduce a little bit of implementation details: although new and make in the escape analysis of the difference is not very big, but the current version of go on the make of the size of the more stringent restrictions, so look at it then that eight shares is still wrong, because the probability of make caused by the escape of a slightly greater than the new. so that the use of new on the use of the, don't need to care about this kind of thing.

Compile Optimization Too Weak for Chicken Drag Escape Analysis

There have been two commits to the go language in the last two years that made me completely lose interest in escape analysis, the first being:7015ed

The change is to alias a local variable so that the compiler doesn't let that local variable escape by mistake.

Why does the compiler let this variable escape? It has to do with the compiler's algorithm for implementing reachability analysis, and it also has to do with the fact that the compiler didn't optimize it, resulting in less accurate analysis.

If you encountered this problem, could you figure out how to fix it? I can't, because this commit was made after a thorough study by the people who develop and maintain the compiler to pinpoint the problem and come up with options, and I'm afraid it's too much for the average person to figure out what's wrong with it.

The other is one I encountered during the 1.24 development cycle. This commit was made to add new functionality to theMade some minor changes, the previous code looked like this:

func (t Time) MarshalText() ([]byte, error) {
        b := make([]byte, 0, len(RFC3339Nano))
        b, err := t.appendStrictRFC3339(b)
        if err != nil {
                return nil, (": " + ())
        }
        return b, nil
}

The new one looks like this:

func (t Time) appendTo(b []byte, errPrefix string) ([]byte, error) {
	b, err := t.appendStrictRFC3339(b)
	if err != nil {
		return nil, (errPrefix + ())
	}
	return b, nil
}

func (t Time) MarshalText() ([]byte, error) {
	return (make([]byte, 0, len(RFC3339Nano)), ": ")
}

In fact, it is the developer to reuse the logic inside, so pull out a separate sub-function, the core content remains unchanged.

Yet the new code, which doesn't look fundamentally different, shows that theMarshalTextThe performance of the program has been improved by 40%.

What's going on? Because nowMarshalTextbecomes simpler, so it can be inlined in many places, and theappendToitself does not allocate memory, which results in the buf that was originally used as a return value because theMarshalTextcan be inlined, the compiler realizes that it doesn't need to be a return value where it is called externally and the size is known, so the case we talked about in section 2 applies and buf doesn't need to be escaped. Not escaping means that no heap memory needs to be allocated, which naturally improves performance.

This is of course due to go's weak inlining optimization, which creates optimization opportunities that are almost impossible in C++ (appendTo is a wrapper with an extra parameter, and normal inlining makes little difference to the original code). This is more or less counter-intuitive in other languages, so at first I thought there was something wrong with the description in the commit, and spent a lot of time troubleshooting and testing before it occurred to me that the inlining might be affecting the escape analysis, and I wasted an afternoon on it.

There are so many of these types of problems, and there are quite a few of them in the issue, that troubleshooting to solve them is difficult if you don't understand exactly what the compiler did and what algorithms it used.

Remember what was said at the beginning, escape analysis is to ease the programmer's burden, and now it's the other way around to ask the programmer to understand the compiler in depth, kind of putting the cart before the horse.

These two commits finally got me to start rethinking the question of how deeply developers need to know about escape analysis.

What to do.

There's actually a lot of folklore about escape analysis, and I'm too lazy to confirm/falsify it all. Here's just what to do as a developer when escape analysis is inherently confusing and complex.

For most developers: as with the title, don't focus too much on escape analysis. Escape analysis should be a wing to boost your efficiency not a shackle when writing code.

After all, just look at the code, you can hardly analyze a so and so, compilation period know the size may escape, looks like do not know the size of the escape may not escape, looks similar to the performance of the code but the performance is very different, the middle also have to be interspersed with the analysis of accessibility and some compilation optimization, corners case much more than imaginable. If you think about these things when writing code, your efficiency will not be high.

Whenever one has to think about escape analysis how-to's, you can use the following steps to help yourself get rid of your dependence on escape analysis:

  1. Does a variable have a longer life cycle than the function that created it?
  2. If so, can you choose to return a "value" instead of a pointer? The overhead of copying is almost negligible when the function can be inlined or the value size is small;
  3. If not, or if you find that the design can be modified to make the variable's lifecycle not so long, then go down
  4. Is the function a performance hotspot?
  5. If not then stop here, otherwise you need to use memprofile and cpuprofile to determine how much damage was done by the escape
  6. The fewer escapes in a performance hotspot, the better, of course, but if the damage from escapes isn't significant in and of itself, then it's not worth moving on
  7. Multiplexing heap memory is often simpler and more intuitive than avoiding escapes, trySomething like that instead of trying to avoid an escape.
  8. At this point, you have to use-gcflags=-m=2Look at why escapes have occurred, some of the reasons are obvious and can be optimized
  9. For the ones that you can't see why they escape, either leave them alone or solve them by means other than go (like assembly).
  10. It's okay to ask for help, but only if they're not mechanically memorizing eight words.

In short, by following common rules such as allocating memory ahead of time if you know the slice size, designing short and concise functions, using pointers sparingly, and so on, you have little need to study escape analysis.

Understanding escape analysis is necessary for developers of compilers, standard libraries, and certain programs with high performance requirements. Because go's performance is not ideal, you have to take advantage of any optimization opportunities to improve performance. For example, I've been asked to make some functions "zero-allocated" when I'm plugging new features into the standard library. Of course, I did not come up to study the escape, but first wrote tests and studied the profile, and only after the results of the escape analysis to do further optimization.

summarize

There are actually a few things left out of this article, such as how arrays and closures behave in escape analysis. Overall they didn't behave much differently than other variables, in looking at the title of the article - so I wouldn't recommend paying too much attention to their escape analysis.

That's why you shouldn't be overly concerned about escape analysis. You should also stop memorizing/carrying/writing eight-word essays about escape analysis.

Most people care about escape analysis, in addition to the interview is for performance, I always say that performance analysis must be combined with profile and benchmark, otherwise out of thin air in order not to escape and cut the foot to fit the shoe, not only a waste of time on the performance of the problem does not help in the slightest.

That said, not knowing about escape analysis in depth and not knowing there is such a thing as escape analysis are two different things, and the latter is really about the same as GOing to learn it for nothing.