Remix.run Logo
foldr 10 days ago

This seems to be a persistent source of confusion. Escape analysis is just an optimization. You don't need to think about it to understand why your Go code behaves the way it does. Just imagine that everything is allocated on the heap and you won't have any surprises.

9rx 4 days ago | parent | next [-]

> This seems to be a persistent source of confusion.

Why? It is the same as in C.

    #include <stdio.h>
    #include <stdlib.h>

    struct slice {
        int *data;
        size_t len;
        size_t cap;
    };

    struct slice readLogsFromPartition() {
        int *data = malloc(2);
        data[0] = 1;
        data[1] = 2;
        return (struct slice){ data, 2, 2 };
    }

    int main() {
        struct slice s = readLogsFromPartition();
        for (int i = 0; i < s.len; i++) {
            printf("%d\n", s.data[i]);
        }
        free(s.data);
    }
simiones 4 days ago | parent | next [-]

The point the GP was making was that the following Go snippet:

  func foo() {
    x := []int { 1 }
    //SNIP 
  }
Could translate to C either as:

  void foo() { 
    int* x = malloc(1 * sizeof(int));
    x[0] = 1;
    //...
  }
Or as

  void foo() { 
    int data[1] = {1};
    int *x = data;
    //...
  }
Depending on the content of //SNIP. However, some people think that the semantics can also match the semantics of the second version in C - when in fact the semantics of the Go code always match the first version, even when the actual implementation is the second version.
9rx 4 days ago | parent [-]

The semantics are clearly defined as being the same as the C code I posted earlier. Why would one try to complicate the situation by thinking that it would somehow magically change sometimes?

simiones 4 days ago | parent [-]

Because people hear that Go supports value types and so is more efficient than Java because it can allocate on the stack*, and so they start thinking that they need to manage the stack.

* Of course, in reality, Java also does escape analysis to allocate on the stack, though it's less likely to happen because of the lack of value types.

9rx 4 days ago | parent [-]

I don't see the difficulty here. The slice is to be thought of as value type, as demonstrated in the C version. Just like in C, you can return it from a function without the heap because it is copied.

foldr 4 days ago | parent | prev [-]

What confuses people is

    int *foo(void) {
        int x = 99;
        return &x; // bad idea
    }
vs.

    func foo() *int {
        x := 99
        return &x // fine
    }
They think that Go, like C, will allocate x on the stack, and that returning a pointer to the value will therefore be invalid.

(Pedants: I'm aware that the official distinction in C is between automatic and non-automatic storage.)

knorker 4 days ago | parent [-]

Yes. That's escape analysis. But this is not what OP did.

What you wrote is not the same in C and Go, because GC and escape analysis. But 9rx is also correct that what OP wrote is the same in C and Go.

So OP almost learned about escape analysis, but their example didn't actually do it. So double confusion on their side.

foldr 4 days ago | parent [-]

Well, my point is that escape analysis has nothing to do with it at the semantic level. So it's actually just 'because GC'. You don't need the concept of escape analysis at all to understand the behavior of the Go example.

knorker 3 days ago | parent [-]

Yeah. That's what I said.

foldr 3 days ago | parent [-]

I mean that escape analysis has nothing to do with my example either, in terms of understand the semantics of the code (so I’m disagreeing with the ‘because GC and escape analysis’ part of your comment).

knorker 3 days ago | parent [-]

Your https://news.ycombinator.com/item?id=46234206 relies on escape analysis though, right?

Escape analysis is the reason your `x` is on the heap. Because it escaped. Otherwise it'd be on the stack.[1]

Now if by "semantics of the code" you mean "just pretend everything is on the heap, and you won't need to think about escape analysis", then sure.

Now in terms of what actually happens, your code triggers escape analysis, and OP does not.

[1] Well, another way to say this I guess is that without escape analysis, a language would be forced to never use the stack.

foldr 3 days ago | parent [-]

Escape analysis clearly isn’t part of the semantics of Go. For that to be the case, the language standard would have to specify exactly which values are guaranteed to be stack allocated. In reality, this depends on size thresholds which can vary from platform to platform or between different versions of the Go compiler. Is the following non-escaping array value stack allocated?

    func pointless() byte {
        var a byte[1024]
        a[0] = 1
        return a[0]
    }
That’s entirely up to the compiler, not something that’s determined by the language semantics. It could vary from platform to platform or compiler version to compiler version. So clearly you don’t need to think about the details of escape analysis to understand what your code does because in many cases you simply won’t know if your value is on the stack or not.
knorker 3 days ago | parent [-]

While you are of course 100% correct, in the context of discussing escape analysis I find it odd to say that it's essentially not "real".

Like any optimization, it makes sense to talk about what "will" happen, even if a language (or a specific compiler) makes no specific promises.

Escape analysis enables an optimization.

I think I understand you to be saying that "escape analysis" is not why returning a pointer to a local works in Go, but it's what allows some variables to be on the stack, despite the ability to return pointers to other "local" variables.

Or similar to how the compiler can allow "a * 6" to never use a mul instruction, but just two shifts and an add.

Which is probably a better way to think about it.

> So clearly you don’t need to think about the details of escape analysis to understand what your code does

Right. To circle back to the context: Yeah, OP thought this was due to escape analysis, and that's why it worked. No, it's just a detail about why other code does something else. (but not really, because OP returned the slice by value)

So I suppose it's more correct to say that we were never discussing escape analysis at all. An escape analysis post would be talking about allocation counts and memory fragmentation, not "why does this work?".

Claude (per OPs post) led them astray.

Yokohiii 4 days ago | parent | prev | next [-]

I am currently learning go and your comment made me sort some things out, but probably in a counterintuitive way.

Assuming to everything allocates on the heap, will solve this specific confusion.

My understanding is that C will let you crash quite fast if the stack becomes too large, go will dynamically grow the stack as needed. So it's possible to think you're working on the heap, but you are actually threshing the runtime with expensive stack grow calls. Go certainly tries to be smart about it with various strategies, but a rapid stack grow rate will have it's cost.

foldr 4 days ago | parent [-]

Go won’t put large allocations on the stack even if escape analysis would permit it, so generally speaking this should only be a concern if you have very deep recursion (in which case you might have to worry about stack overflows anyway).

masklinn 3 days ago | parent | next [-]

> Go won’t put large allocations on the stack even if escape analysis would permit it

Depends what you mean by “large”. As of 1.24 Go will put slices several KB into the stack frame:

    make([]byte, 65536)
Goes on the stack if it does not escape (you can see Go request a large stack frame)

    make([]byte, 65537)
goes on the heap (Go calls runtime.makeslice).

Interestingly arrays have a different limit: they respect MaxStackVarSize, which was lowered from 10MB to 128 KB in 1.24.

If you use indexed slice literals gc does not even check and you can create megabyte-sized slices on the stack.

Yokohiii 3 days ago | parent [-]

There is a option -smallframes that seems to be intended for conservative use cases. Below are the related configs and a test at what point they escape (+1).

  // -smallframes
  // ir.MaxStackVarSize = 64 * 1024
  // ir.MaxImplicitStackVarSize = 16 * 1024
  a := [64 * 1024 +1]byte{}
  b := make([]byte, 0, 16 * 1024 +1)
  // default
  // MaxStackVarSize = int64(128 * 1024)
  // MaxImplicitStackVarSize = int64(64 * 1024)
  c := [128 * 1024 +1]byte{}
  d := make([]byte, 0, 64 * 1024 +1)
Not sure how to verify this, but the assumption you can allocate megabytes on the stack seems wrong. The output of the escape analysis for arrays is different then the make statement:

  test/test.go:36:2: moved to heap: c
Maybe an overlook because it is a bit sneaky?
masklinn 2 days ago | parent [-]

> Not sure how to verify this, but the assumption you can allocate megabytes on the stack seems wrong.

    []byte{N: 0}
Yokohiii a day ago | parent | next [-]

doesn't make sense.

masklinn a day ago | parent [-]

And yet it does: https://godbolt.org/z/h9GW5v3YK

And creates an on-stack slice whose size is only limited by Go's 1GB limit on individual stack frames: https://godbolt.org/z/rKzo8jre6 https://godbolt.org/z/don99e9cn

Yokohiii 16 hours ago | parent [-]

Yea with more context it suddenly makes sense :p

Interesting, [...] syntax works here as expected. So escape analysis simply doesn't look at the element list.

2 days ago | parent | prev [-]
[deleted]
Yokohiii 4 days ago | parent | prev [-]

Escape analysis accounts for size, so it wouldn't even permit it.

The initial stack size seems to be 2kb, a more on a few systems. So far I understand you can allocate a large local i.e. 8kb, that doesn't escape and grow the stack immediately. (Of course that adds up if you have a chain of calls with smaller allocs). So recursion is certainly not the only concern.

foldr 4 days ago | parent [-]

For that to be a problem you either have to have one function that allocates an enormous number of non-escaping objects below the size limit (if the Go compiler doesn't take the total size of all a function's non-escaping allocations into account – I don't know), or a very long series of nested function calls, which in practice is only likely to arise if there are recursive calls.

Yokohiii 4 days ago | parent [-]

I think we mix things up here. But be aware of my newbie knowledge.

I am pretty sure the escape analysis doesn't affect the initial stack size. Escape analysis does determine where an allocation lives. So if your allocation is lower then what escape analysis considers heap and bigger then the initial stack size, the stack needs to grow.

What I am certain about, is that I have runtime.newstack calls accounting for +20% of my benchmark times (go testing). My code is quite shallow (3-4 calls deep) and anything of size should be on the heap (global/preallocated) and the code has zero allocations. I don't use goroutines either, it might me I still make a mistake or it's the overhead from the testing benchmark. But this obviously doesn't seem to be anything super unusual.

foldr 4 days ago | parent [-]

I don't know about your code, but in general, goroutine stacks are designed to start small and grow. There is nothing concerning about this. A call to runtime.newstack triggered by a large stack-allocated value would generally be cheaper than the corresponding heap allocation.

Yokohiii 4 days ago | parent [-]

I found my issue, I was creating a 256 item fixed array of a 2*uint8 struct in my code. That was enough to cause newstack calls. It now went down from varying 10% to roughly 1%. Oddly enough it didn't change the ns/op a bit. I guess some mix of workload related irrelevancy and inaccurate reporting or another oversight on my side.

bonniesimon 10 days ago | parent | prev [-]

Makes sense. I need to rewire how I think about Go. I should see it how I see JS.