Do move free up memory?

For example, I have this struct declaration:

struct Large {
    field1: i32,
    // some fields
    large_vec: Vec<i32>
}

struct Small {
    field1: i32,
    // some fields
    small_vec: Vec<i32>
}

this impl for Large:

impl Large {
    fn reduce(self) -> Small {
        let small_vec = Vec::new();
        // Get some values from large_vec, transform and push into small_vec, no moves
        Small {
            field1: self.field1,
            // ... move all fields from Large to Small, except large_vec
            small_vec
        }
    }
}

and this assignment:

let large = Large {...}; // create Large with a very big Vec
let small = large.reduce();

The question is, in the last assignment, do large has its allocated memory freed up, or is is necessary to go to the end of the function to get its memory deallocated?

self.large_vec goes out of scope at the end of reduce, so it's dropped, and the memory is freed at that moment.

The call to .reduce() moves the entire value of large into the function; so even though the reduce function does not move all the fields out of its argument, the call to the function did move the entire value including the field large.large_vec into the function call, and all the (remaining parts of the) arguments of a function/method are dropped at the end of their scope which is the end of their function body.

Think of the self inside of reduce as a distinct variable from large; the call

let small = large.reduce();

does about as much as

let small = {
    let self_: Large = large; // <- entire value is moved
    // function body of `reduce`:
    let small_vec = Vec::new();
    // Get some values from large_vec, transform and push into small_vec, no moves
    Small {
        field1: self_.field1,
        // ... move all fields from Large to Small, except large_vec
        small_vec
    }
    // end of function body
    // self_.large_vec is dropped here
};
3 Likes

Nice! So, just to be sure that I'm thinking correctly, if there's no logic between the let large = Large {...}; and let small = large.reduce(), the small assignment could be considered as:

let small = {
    let self_: Large = Large {...}; // inlined create Large struct
    // rest of the code
};

Meaning, moving has no cost. Or am I wrong?

Moving a value will do a trivial copy of the value's bytes from the old location into the new location. In the case of moving a Vec<T>, it'll just copy the pointer (just the pointer, not the data behind it), length, and capacity fields into the new variable and treat the previous variable as uninitialized.

In a lot of cases, the optimizer may be able to skip moves that are unnecessary (e.g. let x = vec![...]; let y = x; let z = y; could just be written let z = vec![...]), but that isn't something you will ever really be able to observe.

You can also see this for yourself. I've copied your code into the playground, with the following main() function:

fn main() {
    let large = Large {
        field1: 42,
        large_vec: vec![0; 256],
    };
    print_len(&small);
}

#[inline(never)]
fn print_len(small: &Small) {
    println!("{}", small.small_vec.len());
}

Compiling in release mode generates the following assembly for main():

playground::main:
	pushq	%rbx
	subq	$32, %rsp
	movl	$1024, %edi
	movl	$4, %esi
	callq	*__rust_alloc_zeroed@GOTPCREL(%rip)
	testq	%rax, %rax
	je	.LBB6_4
	movq	.L__unnamed_2(%rip), %rcx
	xorps	%xmm0, %xmm0
	movups	%xmm0, 8(%rsp)
	movl	$42, 24(%rsp)
	movq	%rcx, (%rsp)
	movl	$1024, %esi
	movl	$4, %edx
	movq	%rax, %rdi
	callq	*__rust_dealloc@GOTPCREL(%rip)
	movq	%rsp, %rdi
	callq	playground::print_len
	addq	$32, %rsp
	popq	%rbx
	retq

.LBB6_4:
	movl	$1024, %edi
	movl	$4, %esi
	callq	*alloc::alloc::handle_alloc_error@GOTPCREL(%rip)
	ud2
	movq	%rax, %rbx
	movq	%rsp, %rdi
	callq	core::ptr::drop_in_place<playground::Small>
	movq	%rbx, %rdi
	callq	_Unwind_Resume@PLT
	ud2

It looks awfully complicated, but we are just shuffling some numbers around with no loops or expensive calls to memcpy(). The je .LBB6_4 just checks whether allocating the buffer for our vec![] failed and jumps to a alloc::alloc::handle_alloc_error() call so we can handle it.

1 Like

Yes. Even if the call is not inlined, the argument could be initialized directly in the place on stack and/or in registers where the reduce call expects them; the additional move in source code when doing

let large = Large {...}; // create Large with a very big Vec
let small = large.reduce();

instead of just

let small = Large {...}.reduce();

can be optimized away (and you should expect that such a move is always optimized in release-mode). I double-checked, testing with “#[inline(never)]” on reduce; both versions generate identical code in release code while the former does generate an additional set of “movq” instructions in debug mode.

If reduce is inlined, then even more optimization can happen, of course – omitting even more moves; there would be no need to create a struct Large at all, the compiled code could e.g. directly write its components into the corresponding fields of the Small struct being created.

3 Likes

Very enlightening. Thanks a lot!

This topic was automatically closed 90 days after the last reply. We invite you to open a new topic if you have further questions or comments.