Cost of creating closure vs cost of creating Struct?

Let A = the run time cost of creating a closure that takes M arguments and moves N arguments.

Let B = the run time cost of creating a struct that takes (M+N) arguments (of the same type).

It's probably true that B < A. However, empirically, do we also have bounds of the form A < 2 * B or A < 3 * B ?

EDIT: by 'cost' we can consider memory and CPU cycles separately

I wouldn't be so sure that B is less than A - creating a closure effectively just generates a struct containing the captured variables, so they should be more or less identical at runtime.

5 Likes

Why?

1 Like

At the very [quote="H2CO3, post:3, topic:33101"]

It's probably true that B < A.

Why?
[/quote]

At the very least, A needs to store a pointer to the raw function code. So

B = clone/move M+N arguments
A >= clone/move M+N arguments, add extra pointer to raw function code > clone/move M+N arguments = B

let some_stuff: ST = ...;
let some_other_stuff: SOT = ...;
let closure = |some_argument: SA| {
    use(some_stuff, some_other_stuff)
};
closure(some_argument);

is the same as:

let some_stuff: SS = ...;
let some_other_stuff: SOS = ...;
struct Closure { // defined at compile-time
    some_stuff: [&_] SS,
    some_other_stuff: [&_] SOS,
}
impl Closure { // defined at compile-time
    fn call([&_] self, some_argument: SA) -> ...
    {
        use(self.some_stuff, self.some_other_stuff)
    }
}
let closure = Closure { // created at runtime
    some_stuff: [&_] some_stuff,
    some_other_stuff: [&_] some_other_stuff,
}
closure.call(some_argument);

Ergo, creating a closure == creating a struct with the captured environment, and calling a closure == calling a method of such struct.


See @RustyYato's blog post about closures' sugar for a more detailed description of this closure / object equivalence.

6 Likes

Thanks for the insightful response + link to @RustyYato 's blog post.

Side issue: does this mean my argument about B < A is wrong -- since there is no explicit "pointer to the raw function code" and it seems the "ref to the raw function code" is decided on the type of some Struct (which is created at compile time), so there's zero runtime over head?

1 Like

Exactly, calling a closure dispatches statically to a method call (that's why each closure has a different type than any other one, even when they capture the same environment + have the same API), so there is no extra "function pointer" overhead.

The counter example, of course, is when your closure is a dyn Fn... trait object. Only then will there be a pointer to a vtable holding a pointer to the function's code / body, since because of the type erasure it is no longer possible to store the address of the code within the static information of a type, so it needs to be present at runtime.


This, by the way, is the reason why Rust's <F : Fn...> (f: F) will be even more performant than C taking two parameters: _ (*f) (void * state, ...), void * state. That signature is the one of a f: &[mut] dyn Fn...(...) -> _ argument in Rust. In other words, callbacks in C always use dynamic dispatch, whereas in Rust function genericity / templates where the address of the callback's code is hardcoded into each monomorphised function's body.

2 Likes

All issues resolved -- thanks everyone!

No, that would mean implicit dynamic dispatch for every closure. Instead, Rust implements each closures as a distinct (new) type, implementing some or all of the FnOnce, FnMut, and Fn traits.

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