Basic question on stack allocation

Let's say I have a struct with a new method:

struct Foo {}
impl Foo {
  pub fn new() -> Self {
    Self{}
  }
}

and I have a couple functions that create this foo and pass it along:

fn main() {
  level1(level2(Foo::new()));
}
fn level1(foo:Foo) -> Foo {
  foo
}
fn level2(foo:Foo) -> Foo {
  foo
}

For heap allocation - I think I understand, a pointer is just passed along. But for stack allocation, I don't really get it... is the entire contents of Foo moved into a different address of memory each time it goes out of a functions scope? (how could it not - since the function stack is now gone?) - if so, isn't this moving of whole data structs slow?

You always have to think of these things in two ways: first, the semantics of this kind of thing, and secondly, what happens after optimizations. We can kind of emulate this by looking at the compiled output with no optimizations, and with optimizations.

Sorta hilariously, because your struct has no data, there is literally nothing at runtime, semantically. So functions get called but they don't really do anything.

If we add some data, you'll see a difference:

Semantically, we copy the number through a series of function calls, but it'll get optimized out. We can use std::hint::black_box to stop the 5 from being optimized away too

TL;DR: semantically, yes, the struct gets copied around. But in reality, that doesn't mean that that actually happens.

3 Likes

It depends on various things, including optimizations taken place. In this very situation, there is nothing passed at all to those functions - Foo is zero sized, so it is just semantical, with no inpact of generated code. If Foo would be small, it would be copied to the another function - copy would be as cheap as copying pointer, but it would be cheaper to access it (address of the variable would be just an offset to the stack head).

The tricky things would (or may) happen if Foo is big. I believe, that in such case you can expect, is that the function would get just a pointer to stack - it is 100% guaranteed, that:

  • the former stack frame lives longer than its child, so it is 100% safe to do this
  • the child may take ownership and drop the object after all, because parent know not to do this.

You may check this actually happens using Compiler Explorer mentioned before - just add some data to Foo, but bigger than twice a register size, eg [i32; 100] (tried also with Vec<i32>, but the whole allocation makes things less readable).

Also there is something like return value optimization. In general it is very similar - if you are returning big struct, you may expect, that you would actually pass its pointer to the function, so it would be filled by this function in place it would be actually used - obviously only if compiler thinks it is worth to perform the optimization (i believe that struct being twice register size big is a treshold in C++ - I am not sure about Rust, but it turns out it happens if I added [u32; 100] to your struct)

The issue is obviously how Rust handle complex situations. It may get tricky when you call your function like:

let mut foo = Foo::new();
foo = level1(foo);

because input and output pointers are the same. In this case it is very simple, because it is just memcpy from one location to another (I am supprised there is no check if the pointers are the same, but maybe it is checked by memcpy). However if there is a case that the result is constructed using source in some steps it just may get tricky, but hopefully compiler would do this for you. TBH I expect this to be handled in exactly same way as in clang - I suppose those optimizations are just delegated to be handled by llvm.

1 Like

Awesome, thanks y'all!

This topic was automatically closed 90 days after the last reply. New replies are no longer allowed.