Rust – What .boxed() costs you (and a tiny crate to avoid it)

ae21 pts0 comments

What .boxed() actually costs you (and a tiny crate to avoid it)Lucas' Hut<br>What .boxed() actually costs you (and a tiny crate to avoid it)<br>TLDR<br>.boxed() from the futures crate reads beautifully at the end of an async block, but it's not free: it performs type erasure and hands you a BoxFuture, which means every poll goes through dynamic dispatch.<br>I got curious enough to actually measure it. The runtime overhead of dynamic dispatch is real, but I wondered how real, depending on the context, use case, etc.<br>So I shipped a tiny crate called boxpin that gives you the suffix ergonomics of .boxed() while doing exactly what Box::pin(...) does and nothing more:<br>use boxpin::BoxPinExt;

let future = async { 42 }.pinned();

No type erasure or dynamic dispatch. Same type, same cost as Box::pin(future), and full inlineability.<br>Why I started poking at this<br>Box::pin(...) is what you actually want, most of the time. You have an async fn or an async block, the compiler tells you the future isn't Unpin, you box-pin it and move on.<br>But syntactically Box::pin(...) is a prefix. It wraps the whole expression. If you're chaining async builders or returning a future from inside a match arm, you have to backtrack and add an opening paren way up top. It's annoying. And IMO super unreadable.<br>.boxed() is a suffix. It reads top-to-bottom. You write the future, then say .boxed() and you're done. Beautiful.<br>The catch: .boxed() does more than Box::pin. It also performs type erasure. The return type is BoxFuture, which expands to Pin + Send + 'static>>. That dyn is the part that costs you. Every poll is a vtable lookup, and the compiler can no longer inline through the boundary.<br>Quick refresher on type erasure. When the compiler sees a concrete future type, it can inline the body, fold combinators, and reason about the call as if it were ordinary synchronous code. As soon as you hide that type behind dyn Future, all of that goes away. The future becomes a black box behind a function pointer.

The question I wanted answered: how much does that nicer-looking call actually cost?<br>What the benchmark actually does<br>The full harness lives at github.com/ae2rs/boxpin. It's Criterion, with seven scenarios (immediate_ready, pending_8, pending_64, large_capture_4k, combinator_chain_16, plus two layered families) measured at five life-stages: construct, first_poll, complete_manual, block_on, and inside both flavours of the Tokio runtime.<br>Every scenario compares the same future under three strategies:<br>// 1. no_box: the raw future, baseline<br>let f = make_future();

// 2. concrete_pin: Box::pin, no type erasure<br>let f = Box::pin(make_future());

// 3. boxed_method: futures::FutureExt::boxed, with type erasure<br>let f = make_future().boxed();

concrete_pin is what .pinned() does in the boxpin crate. boxed_method is the .boxed() from the futures crate.<br>I'm not benchmarking allocation cost here. Every strategy that boxes pays for one Box::new, so that part cancels out. The interesting cost is what happens after the future is built: the polls.<br>Setup. All numbers below come from cargo bench (release mode, with Criterion's defaults: 30 samples, 500ms warmup, 1s measurement). Lower is better.

Headline chart: the basic scenarios

A few things jump out.<br>On first_poll, the picture is clean. For pending_8, the raw future takes about 736 ps , concrete_pin is 1.20 ns , and boxed_method jumps to 11.1 ns . That's a roughly 9x slowdown versus the un-erased pin. pending_64 looks almost identical: 732 ps / 1.18 ns / 10.5 ns . The first-poll cost barely depends on how much work the future will eventually do, because we're only paying for the dispatch into the future, not for the future's body. The erased version pays for a vtable call; the concrete one inlines.<br>Once you switch to block_on, the runtime starts to dominate. For pending_8, the three numbers are 18.6 ns , 23.2 ns , and 31.2 ns . The gap between concrete_pin and boxed_method shrinks to about 1.3x, because most of the time is now spent waking and re-polling, not in the dispatch itself.<br>This is what I expected: erasure cost is per-poll, and it shows up most clearly when per-poll work is small.<br>Where it really hurts: layered/middleware stacks<br>The flat numbers are interesting but easy to brush off. The picture gets a lot more dramatic when you stack futures inside futures.<br>The layered scenarios put eight ForwardLayer wrappers around a base future. Think of it as a stand-in for tower-style middleware composition, where each layer wraps and forwards to the next. With concrete types, every layer compiles down to a flat call chain. With type erasure at each layer, every layer adds a dyn Future boundary, and every poll walks through eight vtable lookups before reaching the leaf.

The first-poll numbers for layered_pending_8_l8 are 756 ps (no box), 1.18 ns (concrete pin), and 99.1 ns (boxed). That's an 84x gap between the two boxed strategies. Same shape for layered_pending_64_l8: concrete pin is 1.18 ns, boxed is 95.6 ns ,...

future boxed type erasure crate poll

Related Articles