Support for transparent async function inlining

Idea:

An .await invocation on a #[inline(always)] async fn should make that .await behave as a no-op and execute given the async function in the context of the current future, without yielding control back to the executor.

Usecase:

Performance. Sometimes you don't want to yield control, for data-race / latency / performance reasons, but you still want to be able to partition your async functions into more managable chunks. This would allow that.

Current workaround:

A macro "inline function generator " (code block with parameter bindings). This way, I can create a "fake" function which uses .await and be able to call it from within an async context without an executor yield.

https://gist.github.com/vjancik/0ae810

#![allow(unused_macros)]

macro_rules! with_dollar_sign {
    ($($body:tt)*) => {
        macro_rules! __with_escape { $($body)* }
        __with_escape!($);
    }
}

macro_rules! inline_fn {
    ( $func:ident, $( $par_name:ident $(: $par_type:ty )?,)* $func_body:block) => {
        paste::paste! {
            with_dollar_sign! { ($d:tt) => {
                macro_rules! $func {
                    ( $( $par_name = $d [< $par_name _par_val >]:expr ),* ) => {{
                        $(
                            let $par_name $(: $par_type)? = $d [< $par_name _par_val >];
                        )*
                        $func_body
                    }}
                }
            }}
        }
    };
}

#[cfg(test)]
mod tests {
    use tokio::runtime;

    
    inline_fn!(some_func, some_var: &mut u32, some_var2, {
        *some_var -= 1;
        assert_eq!(*some_var, 4);
        assert_eq!(some_var2, 6);
    });

    #[test]
    fn inline_test() {
        let mut captured = 5;
        some_func!(some_var = &mut captured, some_var2 = 6);
    }

    inline_fn!(inline_async, {
        (async {
            println!("Executed asynchronously");
            return 0u8
        }).await
    });

    async fn async_routine() -> u8 {
        inline_async!()
    }

    #[test]
    fn inline_async_test() {
        let runtime = runtime::Builder::new_current_thread().build().unwrap();
        runtime.block_on(async_routine());
    }
}

Note: That is the first "real" macro I've ever written in Rust, so any improvement suggestions are very welcome! :slight_smile:

AFAIK, await does not yield control to the executor unless the future you called it on does so. In other words, it seems like you are assuming performance impacts and behavior that doesn't actually happen in Rust, perhaps due to the fact that async/await in other programming languages might in fact always involve the executor, comparable to e.g. doing

tokio::spawn(async_function_call(params)).await

or

tokio::task::yield_now.await
async_function_call(params).await

instead of

async_function_call(params).await

I did have this misconception. So if I understand correctly now, an async function executing an .await call will first try to resolve that "child" future by polling it, and only return Poll::Pending to it's poller if the child future can't resolve synchronously?

If that's the case, what exactly does #[inline] and #[inline(always)] do on an async fn?

And thank you for your information.

I believe the #[inline] is only propagated to the desugared "constructor" for the future, i.e.

#[inline]
async fn foo() {}

desugars to something like

#[inline]
fn foo() -> impl Future {
    struct AnonFuture { ... }
    impl Future for AnonFuture { ... }
    AnonFuture { ... }
}

so will more strongly hint that that construction should be inlined. But given that it's a trivial function it is likely to be inlined anyway.

IMO the #[inline] should also propagate onto the generated poll function to get proper inlining of the future implementation, essentially something like

#[inline]
fn foo() -> impl Future {
    struct AnonFuture { ... }
    impl Future for AnonFuture {
        type Output = ();

        #[inline]
        fn poll(...) { .. }
    }
    AnonFuture { ... }
}

(but actually on an impl Generator, not an impl Future; unstable implementation details etc.)

1 Like

The weird thing is that calling an async fn method doesn't actually run any code. It's very different from something like Promise in JS where code is run eagerly and an object exists for each call.

Rust translates all the async code into one giant struct, which is not just one function at a time, but the whole call graph of all async functions calling each other together. That's why async functions can't be directly recursive, because it'd make an infinitely large struct for infinitely deep call graph.

So when you call an async fn function, it only saves its arguments into its state-machine struct. The real work happens in an auto-generated poll method later.

1 Like