MIR validation at `optimized_mir` re-proves `Send` for async bodies boxed in place, and the proof is never cached
Nobody has claimed this yet.
- Dominant language
- Rust
- Stars
- 119k
- Forks
- 16.1k
- PR merge metrics
- PR metrics pending
Description
Disclosure: This issue was generated from a real-world codebase where the problem caused a significant (minute-plus) impact on build times, and an LLM was used to distill it into a reproducible test case. The LLM's output is included below. I have reviewed that the fix the LLM proposed, or the workaround the LLM proposed, made a significant impact in our real-world codebase, and I am filing this bug to see if there might be an upstream fix that would address the issue.
For an async body that is boxed and unsized inside the function that owns it — Box::pin(async move { … }) returned as Pin<Box<dyn Future + Send>>, the shape #[async_trait] expands every method to — the coroutine's Send proof is repeated by the MIR validator when optimized_mir changes the body's phase to Runtime(Optimized). That validation runs in every build (it does not depend on -Zvalidate-mir or debug assertions), it starts from a fresh inference context, and nothing it proves is shared with borrowck, which already proved the same thing. When the coroutine holds a large type graph across an .await, this one check costs as much as borrowck's own Send check and shows up as optimized_mir self time.
I tried this code
repro.rs is self-contained (no dependencies). Two macros stamp out a layered type graph of 320 structs — each struct holds every struct of the next layer behind Arc<Mutex<_>>/HashMap<String, Vec<Arc<_>>>/Option<Arc<RwLock<_>>>, plus a Box<dyn Handler + Send + Sync> leaf — and 150 functions that each return an async body which holds &Root across two .awaits, boxed to dyn Future + Send. The --cfg helper variant moves the unsize cast out of the body's owner into a generic boxed() helper; the future is otherwise identical.
// rustc --edition 2021 --crate-type lib repro.rs (A: unsize cast in the async body's owner)
// rustc --edition 2021 --crate-type lib repro.rs --cfg helper (B: same future, cast inside a generic helper)
#![allow(non_snake_case, dead_code)]
use std::collections::HashMap;
use std::future::Future;
use std::pin::Pin;
use std::sync::{Arc, Mutex, RwLock};
pub trait Handler { fn call(&self, n: u64) -> u64; }
pub type BoxFut<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
// A layered type graph: every struct on the left of `=>` holds every struct on the right
// (behind Arc/Mutex/HashMap/RwLock), plus a `Box<dyn Handler + Send + Sync>` leaf.
macro_rules! node {
($s:ident [$($n:ident)*]) => { pub struct $s {
pub h: Box<dyn Handler + Send + Sync>,
$( pub $n: (Arc<Mutex<$n>>, HashMap<String, Vec<Arc<$n>>>, Option<Arc<RwLock<$n>>>), )*
} };
}
macro_rules! layer {
($($s:ident)* => $next:tt) => { $( node!($s $next); )* };
($($s:ident)*) => { $( pub struct $s { pub h: Box<dyn Handler + Send + Sync>, pub v: Arc<Mutex<Vec<String>>> } )* };
}
layer!(A0 A1 A2 A3 A4 A5 A6 A7 A8 A9 A10 A11 A12 A13 A14 A15 A16 A17 A18 A19 A20 A21 A22 A23 A24 A25 A26
A27 A28 A29 A30 A31 A32 A33 A34 A35 A36 A37 A38 A39
=> [B0 B1 B2 B3 B4 B5 B6 B7 B8 B9 B10 B11 B12 B13 B14 B15 B16 B17 B18 B19 B20 B21 B22 B23 B24 B25 B26
B27 B28 B29 B30 B31 B32 B33 B34 B35 B36 B37 B38 B39]);
layer!(B0 B1 B2 B3 B4 B5 B6 B7 B8 B9 B10 B11 B12 B13 B14 B15 B16 B17 B18 B19 B20 B21 B22 B23 B24 B25 B26
B27 B28 B29 B30 B31 B32 B33 B34 B35 B36 B37 B38 B39
=> [C0 C1 C2 C3 C4 C5 C6 C7 C8 C9 C10 C11 C12 C13 C14 C15 C16 C17 C18 C19 C20 C21 C22 C23 C24 C25 C26
C27 C28 C29 C30 C31 C32 C33 C34 C35 C36 C37 C38 C39]);
layer!(C0 C1 C2 C3 C4 C5 C6 C7 C8 C9 C10 C11 C12 C13 C14 C15 C16 C17 C18 C19 C20 C21 C22 C23 C24 C25 C26
C27 C28 C29 C30 C31 C32 C33 C34 C35 C36 C37 C38 C39
=> [D0 D1 D2 D3 D4 D5 D6 D7 D8 D9 D10 D11 D12 D13 D14 D15 D16 D17 D18 D19 D20 D21 D22 D23 D24 D25 D26
D27 D28 D29 D30 D31 D32 D33 D34 D35 D36 D37 D38 D39]);
layer!(D0 D1 D2 D3 D4 D5 D6 D7 D8 D9 D10 D11 D12 D13 D14 D15 D16 D17 D18 D19 D20 D21 D22 D23 D24 D25 D26
D27 D28 D29 D30 D31 D32 D33 D34 D35 D36 D37 D38 D39
=> [E0 E1 E2 E3 E4 E5 E6 E7 E8 E9 E10 E11 E12 E13 E14 E15 E16 E17 E18 E19 E20 E21 E22 E23 E24 E25 E26
E27 E28 E29 E30 E31 E32 E33 E34 E35 E36 E37 E38 E39]);
layer!(E0 E1 E2 E3 E4 E5 E6 E7 E8 E9 E10 E11 E12 E13 E14 E15 E16 E17 E18 E19 E20 E21 E22 E23 E24 E25 E26
E27 E28 E29 E30 E31 E32 E33 E34 E35 E36 E37 E38 E39
=> [F0 F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12 F13 F14 F15 F16 F17 F18 F19 F20 F21 F22 F23 F24 F25 F26
F27 F28 F29 F30 F31 F32 F33 F34 F35 F36 F37 F38 F39]);
layer!(F0 F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12 F13 F14 F15 F16 F17 F18 F19 F20 F21 F22 F23 F24 F25 F26
F27 F28 F29 F30 F31 F32 F33 F34 F35 F36 F37 F38 F39
=> [G0 G1 G2 G3 G4 G5 G6 G7 G8 G9 G10 G11 G12 G13 G14 G15 G16 G17 G18 G19 G20 G21 G22 G23 G24 G25 G26
G27 G28 G29 G30 G31 G32 G33 G34 G35 G36 G37 G38 G39]);
layer!(G0 G1 G2 G3 G4 G5 G6 G7 G8 G9 G10 G11 G12 G13 G14 G15 G16 G17 G18 G19 G20 G21 G22 G23 G24 G25 G26
G27 G28 G29 G30 G31 G32 G33 G34 G35 G36 G37 G38 G39
=> [H0 H1 H2 H3 H4 H5 H6 H7 H8 H9 H10 H11 H12 H13 H14 H15 H16 H17 H18 H19 H20 H21 H22 H23 H24 H25 H26
H27 H28 H29 H30 H31 H32 H33 H34 H35 H36 H37 H38 H39]);
layer!(H0 H1 H2 H3 H4 H5 H6 H7 H8 H9 H10 H11 H12 H13 H14 H15 H16 H17 H18 H19 H20 H21 H22 H23 H24 H25 H26
H27 H28 H29 H30 H31 H32 H33 H34 H35 H36 H37 H38 H39);
pub struct Root {
pub a0: Arc<A0>, pub a1: Arc<A1>, pub a2: Arc<A2>, pub a3: Arc<A3>, pub a4: Arc<A4>, pub a5:
Arc<A5>, pub a6: Arc<A6>, pub a7: Arc<A7>, pub a8: Arc<A8>, pub a9: Arc<A9>, pub a10: Arc<A10>,
pub a11: Arc<A11>, pub a12: Arc<A12>, pub a13: Arc<A13>, pub a14: Arc<A14>, pub a15: Arc<A15>,
pub a16: Arc<A16>, pub a17: Arc<A17>, pub a18: Arc<A18>, pub a19: Arc<A19>, pub a20: Arc<A20>,
pub a21: Arc<A21>, pub a22: Arc<A22>, pub a23: Arc<A23>, pub a24: Arc<A24>, pub a25: Arc<A25>,
pub a26: Arc<A26>, pub a27: Arc<A27>, pub a28: Arc<A28>, pub a29: Arc<A29>, pub a30: Arc<A30>,
pub a31: Arc<A31>, pub a32: Arc<A32>, pub a33: Arc<A33>, pub a34: Arc<A34>, pub a35: Arc<A35>,
pub a36: Arc<A36>, pub a37: Arc<A37>, pub a38: Arc<A38>, pub a39: Arc<A39>,
}
async fn step(x: u64) -> u64 { std::future::ready(x).await }
#[cfg(helper)]
fn boxed<'a, F: Future + Send + 'a>(f: F) -> BoxFut<'a, F::Output> { Box::pin(f) }
// Many async bodies, each holding `&Root` across `.await` (so `Root: Sync` is part of the
// coroutine's `Send` proof), each boxed to `dyn Future + Send`.
macro_rules! ops {
($($f:ident)*) => { $( pub fn $f(root: &Root) -> BoxFut<'_, u64> {
let fut = async move {
let r: &Root = root;
let x = step(1).await;
let y = step(x).await;
x + y + std::mem::size_of_val(r) as u64
};
#[cfg(not(helper))] return Box::pin(fut); // what `#[async_trait]` expands to
#[cfg(helper)] return boxed(fut);
} )* };
}
ops!(op000 op001 op002 op003 op004 op005 op006 op007 op008 op009 op010 op011 op012 op013 op014
op015 op016 op017 op018 op019 op020 op021 op022 op023 op024 op025 op026 op027 op028 op029 op030
op031 op032 op033 op034 op035 op036 op037 op038 op039 op040 op041 op042 op043 op044 op045 op046
op047 op048 op049 op050 op051 op052 op053 op054 op055 op056 op057 op058 op059 op060 op061 op062
op063 op064 op065 op066 op067 op068 op069 op070 op071 op072 op073 op074 op075 op076 op077 op078
op079 op080 op081 op082 op083 op084 op085 op086 op087 op088 op089 op090 op091 op092 op093 op094
op095 op096 op097 op098 op099 op100 op101 op102 op103 op104 op105 op106 op107 op108 op109 op110
op111 op112 op113 op114 op115 op116 op117 op118 op119 op120 op121 op122 op123 op124 op125 op126
op127 op128 op129 op130 op131 op132 op133 op134 op135 op136 op137 op138 op139 op140 op141 op142
op143 op144 op145 op146 op147 op148 op149);
rustc --edition 2021 --crate-type lib repro.rs # A: cast in the body's owner
rustc --edition 2021 --crate-type lib repro.rs --cfg helper # B: cast in a generic helper
RUSTC_BOOTSTRAP=1 rustc --edition 2021 --crate-type lib repro.rs -Zvalidate-mir=yes # C
Timings below are wall time without profiling, plus per-query self time from -Zself-profile (the trait solving here runs inside fulfillment, so it is charged to the query that asked, not to evaluate_obligation).
I expected to see this happen
One Send proof per async body from the type checker (check_coroutine_obligations), and the same compile time whether the future is unsized in the body's owner or in a helper. MIR validation should not repeat trait solving that borrowck already did, least of all when -Zvalidate-mir is off.
Instead, this happened
| wall | optimized_mir self time |
check_coroutine_obligations |
type_op_prove_predicate (borrowck) |
codegen_select_candidate (mono collector) |
|
|---|---|---|---|---|---|
A: Box::pin(fut) in the body's owner |
17.7 s | 4.95 s (150 bodies, ~33 ms each) | 4.69 s | 4.79 s | 4.90 s |
A + -Zvalidate-mir=no (the default, spelled out) |
17.6 s | 4.35 s | 4.11 s | 4.20 s | 4.29 s |
A + -Zmir-opt-level=0 |
18.5 s | 4.27 s | 4.05 s | 4.12 s | 4.23 s |
A, --edition 2024 |
17.5 s | (same) | |||
B: boxed(fut) (--cfg helper) |
13.5 s | 0.01 s | 4.01 s | 3.97 s | 4.20 s |
C: A + -Zvalidate-mir=yes |
185 s | 98 s | 4.12 s | 3.99 s | 4.24 s |
- In A,
optimized_mirspends ~33 ms of its own time perop###body — the same ~30 mscheck_coroutine_obligationsspends proving that body's coroutineSend. The MIR passes themselves total under 0.1 s for the whole crate; the time sits at the phase change, insidevalidate_body. - In B the future, its captured
&Root, and itsSendproof at borrowck are unchanged; only the unsize cast moved into a generic body where the validator seesBox<F>: CoerceUnsized<Box<dyn Future + Send>>withF: Sendin the param-env — one trivial goal.optimized_mirdrops from 4.95 s to 0.01 s and the compile gets 4.2 s faster. -Zvalidate-mir=noand-Zmir-opt-level=0change nothing: the final-phase validation ignores both.- C shows that no caching happens anywhere along this path: with validation after every pass, the same proof is repeated after every pass (
mir_built28 s,mir_promoted12 s,mir_drops_elaborated_and_const_checked40 s,optimized_mir98 s) whilecheck_coroutine_obligationsstays at 4 s.
Meta
rustc --version --verbose
rustc 1.97.1 (8bab26f4f 2026-07-14)
binary: rustc
commit-hash: 8bab26f4f68e0e26f0bb7960be334d5b520ea452
commit-date: 2026-07-14
host: x86_64-unknown-linux-gnu
release: 1.97.1
LLVM version: 21.1.0
Release build, no debug assertions. Single-threaded rustc on an idle machine.
Why the second proof is not cached
compiler/rustc_mir_transform/src/pass_manager.rs,run_passes_inner(lines 355–361 at 8bab26f4f): at a phase change the body is validated whenvalidate_each & validate_mir & !should_skip()ornew_phase == MirPhase::Runtime(RuntimePhase::Optimized). The second disjunct makes the validation at the end ofoptimized_mirunconditional.compiler/rustc_mir_transform/src/validate.rs: theCastKind::PointerCoercion(PointerCoercion::Unsize, _)arm ofvisit_rvalue(line 1328) callspredicate_must_hold_modulo_regions(<op_ty as CoerceUnsized<target_type>>). That helper (line 596) builds a freshinfer_ctxt().build_with_typing_env(self.typing_env)—TypingMode::PostAnalysisfor runtime MIR — registers the predicate in anObligationCtxt, and runsevaluate_obligations_error_on_ambiguity(). ForBox<{async block}>: CoerceUnsized<Box<dyn Future + Send>>the nested{async block}: Sendgoal walks the coroutine witness and everything it captures.- Nothing on that path stores a result, and nothing consults borrowck's: borrowck proved the same goal under
TypingMode::Analysis, and the validator's proof is a plain fulfillment run in a throwaway context. The only shared cache it can touch is theevaluate_obligationquery that fulfillment consults for closed nested goals — and for a type graph like this one that does not short-circuit the walk (replace theBox<dyn Handler + Send + Sync>leaves withBox<[u8]>and the whole file compiles in 1.5 s in both variants, so the per-proof cost of trait-object leaves is a separate problem; here it only makes the repeated proof visible). Row C is the direct measurement: every validation is a full re-proof, in the same typing mode or not.
For completeness, the profile shows two more full proofs of the same goal that this report is not about: borrowck's type_op_prove_predicate for the cast's CoerceUnsized obligation (or, in B, for boxed's F: Send bound) alongside check_coroutine_obligations, and the monomorphization collector's codegen_select_candidate for Pin<Box<{async block}>>: CoerceUnsized<Pin<Box<dyn Future + Send>>> (Pin is a user CoerceUnsized impl, so find_tails_for_unsizing goes through custom_coerce_unsize_info; a bare Box→Box cast is special-cased and skips it). Each costs the same ~30 ms per body.
Real-world impact
On a ~400k-line async server crate built with #[async_trait], the profile splits the auto-trait solving into 53 s under check_coroutine_obligations and 50 s under optimized_mir, of a 250 s build — the second 50 s is this re-proof.
Workaround
Do not unsize the future in the body that owns the coroutine; go through a generic helper (variant B above, or futures::FutureExt::boxed):
fn boxed<'a, F: Future + Send + 'a>(f: F) -> Pin<Box<dyn Future<Output = F::Output> + Send + 'a>> { Box::pin(f) }
The validator then proves Box<F>: CoerceUnsized<…> once, in optimized_mir(boxed), against F: Send in the param-env. This is not available to #[async_trait] users without changing the macro's expansion, and -Zvalidate-mir=no does not help.
Possible fixes
The cheapest is to stop re-proving CoerceUnsized in the unconditional final-phase validation: gate the Unsize arm's trait solving on tcx.sess.opts.unstable_opts.validate_mir (as visit_operand already does for its "somewhat expensive" Copy check), so a default build only pays for the structural checks at Runtime(Optimized). Alternatively, skip the CoerceUnsized proof for Unsize casts that typeck accepted and no MIR pass introduced, or make the final-phase validation honor -Zvalidate-mir like every other validation point. Any of these removes the optimized_mir column above.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with repro.rs and the reported rustc commands, then read compiler/rustc_mir_transform/src/pass_manager.rs around run_passes_inner and validate.rs around visit_rvalue and predicate_must_hold_modulo_regions. Confirm the repeated Send proof and its phase-change trigger with profiling. Done means optimized_mir no longer repeats the expensive proof unnecessarily while MIR validation remains correct.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- rust
- Domain
- compilers, performance
- Issue type
- Bug
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 35/100