From 47858465f83ca1b8a2563bb49a2eab9d181a4dfe Mon Sep 17 00:00:00 2001 From: Yijun Yu Date: Sun, 21 Jun 2026 15:25:08 +0000 Subject: [PATCH 1/6] Add -Zdead-fn-elimination: skip codegen of functions unreachable from the entry point Adds an unstable flag that, for binary crates, computes the set of functions reachable from the entry point (via a MIR call-graph BFS seeded with the reachability set, the entry fn, and vtable-constructed trait methods) and excludes the rest from codegen by filtering them out of the mono item set before partitioning. The analysis is conservative: it never eliminates a function in the collected mono set, lang items, exported symbols, the entry fn, drop glue, generics, or async fns. A debug-assertions invariant checks reachable_set is a subset of the post-BFS set. The flag is inert when unset. --- .../rustc_monomorphize/src/dead_fn_elim.rs | 532 ++++++++++++++++++ compiler/rustc_monomorphize/src/lib.rs | 2 + .../rustc_monomorphize/src/partitioning.rs | 16 +- compiler/rustc_session/src/options.rs | 2 + 4 files changed, 551 insertions(+), 1 deletion(-) create mode 100644 compiler/rustc_monomorphize/src/dead_fn_elim.rs diff --git a/compiler/rustc_monomorphize/src/dead_fn_elim.rs b/compiler/rustc_monomorphize/src/dead_fn_elim.rs new file mode 100644 index 0000000000000..00fc858cf4385 --- /dev/null +++ b/compiler/rustc_monomorphize/src/dead_fn_elim.rs @@ -0,0 +1,532 @@ +//! Dead function elimination via BFS reachability — `-Z dead-fn-elimination`. +//! +//! Identifies functions unreachable from the binary entry point via a breadth-first +//! search over the call graph, then marks them so they can be removed from the set of +//! monomorphized items before codegen. +//! +//! This pass is scoped to binary crates only (executables and `bin` test harnesses). +//! Library crate types early-return at the top of [`run_analysis`] because their public +//! items are reachable by definition (downstream crates may call any `pub fn`) and there +//! is no entry point to seed the search from. + +use std::cell::RefCell; +use std::collections::VecDeque; +use std::panic; + +use rustc_data_structures::fx::{FxHashSet, FxIndexMap, FxIndexSet}; +use rustc_hir::def::DefKind; +use rustc_middle::middle::codegen_fn_attrs::CodegenFnAttrFlags; +use rustc_middle::mir::{Body, TerminatorKind}; +use rustc_middle::mono::MonoItem; +use rustc_middle::ty::{self, Ty, TyCtxt}; +use rustc_span::def_id::DefId; + +// `thread_local!` keeps the footprint of this experimental flag minimal. If the flag is +// stabilized, this state should move to a `GlobalCtxt` field or a dedicated query so the +// reachable set is computed and cached through the normal query machinery. +thread_local! { + /// Trait `DefId` indices seen in vtable constructions (`dyn Trait` casts). + static VTABLE_TRAITS: RefCell> = RefCell::new(FxHashSet::default()); + + /// Function `DefId`s whose address has been taken. Populated during MIR traversal by + /// [`scan_for_address_taken`]. These are unioned into the BFS seeds so they survive the + /// call graph: an indirect call site is not visible to direct-call BFS. + static ADDRESS_TAKEN: RefCell> = RefCell::new(FxHashSet::default()); + + /// Functions identified as unreachable and safe to eliminate. Populated by + /// [`run_analysis`]; read via [`is_eliminable`]. + static ELIMINABLE_DEF_IDS: RefCell> = RefCell::new(FxHashSet::default()); +} + +/// Encode a `DefId` as a `u64` key: high 32 bits = crate index, low 32 bits = item index. +#[inline] +fn def_id_key(def_id: DefId) -> u64 { + ((def_id.krate.as_u32() as u64) << 32) | (def_id.index.as_u32() as u64) +} + +/// Run BFS reachability analysis and populate `ELIMINABLE_DEF_IDS`. +/// +/// `mono_items` is the set of monomorphized items already collected by the caller; it is +/// used as the ground truth for [`is_safe_to_eliminate`], so this function never re-enters +/// the monomorphization collection query. +pub(crate) fn run_analysis(tcx: TyCtxt<'_>, mono_items: &[MonoItem<'_>]) { + // Skip analysis entirely for library crates: we never eliminate public items, so the + // only candidates would be private functions — but without a binary entry point there + // are no seeds, meaning every private function would be eliminated incorrectly. + // Returning early is safe and avoids MIR traversal overhead. + if tcx.entry_fn(()).is_none() { + return; + } + + // Build the set of `DefId`s that the collector kept. Dropping any of these would + // dangle a symbol, so they are never eligible for elimination. + let mono_def_ids: FxHashSet = mono_items + .iter() + .filter_map(|item| match item { + MonoItem::Fn(instance) => Some(instance.def_id()), + MonoItem::Static(def_id) => Some(*def_id), + _ => None, + }) + .collect(); + + // Build the call graph (vtable constructions are scanned inline by `add_mir_edges`, + // which also records address-taken functions). + let call_graph = build_call_graph(tcx); + + // BFS from entry seeds. Vtable traits and address-taken functions were populated by + // `build_call_graph`; the latter are unioned into the seed set below so that functions + // reached only through indirect calls survive. + let mut seeds = collect_seeds(tcx); + // Iteration order over the `FxHashSet` does not affect the resulting seed set. + #[allow(rustc::potential_query_instability)] + ADDRESS_TAKEN.with(|a| { + for &key in a.borrow().iter() { + seeds.insert(key); + } + }); + let reachable = run_bfs(seeds, &call_graph); + + // Every item in `reachable_set` was unioned into the seeds (via `collect_seeds`), so + // BFS cannot drop it. This invariant guards against future regressions. + #[cfg(debug_assertions)] + { + let reachable_set = tcx.reachable_set(()); + for &local_def_id in tcx.mir_keys(()) { + if reachable_set.contains(&local_def_id) { + let key = def_id_key(local_def_id.to_def_id()); + debug_assert!( + reachable.contains(&key), + "invariant violated: reachable_set item {key:?} missing from post-BFS set" + ); + } + } + } + + // Mark unreachable and safe functions as eliminable. + let mut count = 0usize; + ELIMINABLE_DEF_IDS.with(|e| { + let mut eliminable = e.borrow_mut(); + for &local_def_id in tcx.mir_keys(()) { + let def_id = local_def_id.to_def_id(); + if !tcx.def_kind(def_id).is_fn_like() { + continue; + } + let key = def_id_key(def_id); + if !reachable.contains(&key) && is_safe_to_eliminate(tcx, def_id, &mono_def_ids) { + eliminable.insert(def_id.index.as_u32() as u64); + count += 1; + } + } + }); + + if count > 0 { + tcx.sess.dcx().note(format!( + "{count} unreachable functions excluded from codegen by -Z dead-fn-elimination" + )); + } +} + +/// Returns `true` if the given local `DefId` index was identified as eliminable. Callers +/// use this for per-item O(1) lookups, so it avoids cloning the set. +pub(crate) fn is_eliminable(idx: u64) -> bool { + ELIMINABLE_DEF_IDS.with(|e| e.borrow().contains(&idx)) +} + +/// Collect the BFS seed set: everything that must be kept regardless of the call graph. +fn collect_seeds(tcx: TyCtxt<'_>) -> FxHashSet { + let mut seeds = FxHashSet::default(); + + // `reachable_set` is the non-eliminable lower bound. It already covers public items by + // effective visibility, lang items, trait impl items, custom-linkage symbols + // (`#[no_mangle]`/`#[used]`/`#[export_name]`), foreign-item-symbol aliases, and + // (transitively) const-initializer references — including the test-harness `TESTS` + // slice. See `compiler/rustc_passes/src/reachable.rs`. + let reachable_set = tcx.reachable_set(()); + for &local_def_id in tcx.mir_keys(()) { + if reachable_set.contains(&local_def_id) { + seeds.insert(def_id_key(local_def_id.to_def_id())); + } + } + + // The entry function is a binary-specific seed that `reachable_set` does not provide. + if let Some((entry_def_id, _)) = tcx.entry_fn(()) { + seeds.insert(def_id_key(entry_def_id)); + } + + // Vtable-constructed trait impl methods (local). Dynamic dispatch is invisible to the + // call-graph BFS, so methods of any trait used as `dyn Trait` must be seeded. + for &local_def_id in tcx.mir_keys(()) { + let def_id = local_def_id.to_def_id(); + if tcx.def_kind(def_id) != DefKind::AssocFn { + continue; + } + let assoc_item = tcx.associated_item(def_id); + if let Some(trait_method_def_id) = assoc_item.trait_item_def_id() { + let trait_def_id = tcx.parent(trait_method_def_id); + if tcx.is_dyn_compatible(trait_def_id) { + let trait_idx = trait_def_id.index.as_u32() as u64; + if VTABLE_TRAITS.with(|v| v.borrow().contains(&trait_idx)) { + seeds.insert(def_id_key(def_id)); + } + } + } + } + seeds +} + +/// Build call graph edges for all local (same-crate) fn-like items. +/// +/// Cross-crate edges are intentionally not added: `is_safe_to_eliminate` rejects any +/// non-local `DefId`, so extern-crate edges could never demote an item from the eliminable +/// set. Cross-crate effects are captured instead by `reachable_set` and address-taken +/// seeding. +fn build_call_graph(tcx: TyCtxt<'_>) -> FxIndexMap> { + let mut graph: FxIndexMap> = FxIndexMap::default(); + for &local_def_id in tcx.mir_keys(()) { + let def_id = local_def_id.to_def_id(); + let def_kind = tcx.def_kind(def_id); + if def_kind.is_fn_like() { + add_mir_edges(tcx, def_id, &mut graph); + } else if matches!( + def_kind, + DefKind::Const { .. } | DefKind::Static { .. } | DefKind::AssocConst { .. } + ) { + // A `const FLAGS: &[&dyn Flag] = &[..]` performs its `Unsize` coercions inside + // a const/static initializer rather than in function-body MIR. By the time we + // read it the coercions are const-evaluated into an allocation whose provenance + // points at `GlobalAlloc::VTable`/`GlobalAlloc::Function`, which + // `scan_for_address_taken` (it only walks fn MIR) misses. We must walk the + // evaluated allocation graph to seed `VTABLE_TRAITS` and `ADDRESS_TAKEN`, + // otherwise impl methods reachable only through such const vtables would be + // wrongly eliminated. + scan_const_static_allocs(tcx, def_id); + } + } + graph +} + +/// Read MIR for `def_id` and insert call edges into `graph`. Silently skips if MIR is +/// unavailable or if `optimized_mir` panics. +fn add_mir_edges(tcx: TyCtxt<'_>, def_id: DefId, graph: &mut FxIndexMap>) { + if !tcx.is_mir_available(def_id) { + return; + } + let Ok(body) = panic::catch_unwind(panic::AssertUnwindSafe(|| tcx.optimized_mir(def_id))) + else { + return; + }; + scan_for_address_taken(body); + let caller_key = def_id_key(def_id); + let edges = graph.entry(caller_key).or_default(); + for bb in body.basic_blocks.iter() { + // Direct call edges. + if let TerminatorKind::Call { func, .. } = &bb.terminator().kind { + if let rustc_middle::mir::Operand::Constant(c) = func { + if let ty::FnDef(callee_def_id, _) = c.const_.ty().kind() { + edges.insert(def_id_key(*callee_def_id)); + } + } + } + // A closure/coroutine is created via an `Aggregate` rvalue and invoked later + // through `Fn*`/`poll`, so there is no direct `Call` to it from its creator; + // without this edge the closure body (and everything it calls) would be wrongly + // unreachable. Likewise any `FnDef` mentioned as a value (passed to an adaptor such + // as `iter.map(f)`) must become an edge, not only direct call targets. + for stmt in &bb.statements { + use rustc_middle::mir::{AggregateKind, Rvalue, StatementKind}; + if let StatementKind::Assign(box (_, rvalue)) = &stmt.kind { + // Closure / coroutine / coroutine-closure construction. + if let Rvalue::Aggregate(box kind, _) = rvalue { + match kind { + AggregateKind::Closure(cdef, _) + | AggregateKind::Coroutine(cdef, _) + | AggregateKind::CoroutineClosure(cdef, _) => { + edges.insert(def_id_key(*cdef)); + } + _ => {} + } + } + // Any operand that names a function by value (a fn item passed around). + for op in rvalue_operands(rvalue) { + if let rustc_middle::mir::Operand::Constant(c) = op { + if let ty::FnDef(fdef, _) = c.const_.ty().kind() { + edges.insert(def_id_key(*fdef)); + } + } + } + } + } + } +} + +/// Best-effort iterator over the operands an `Rvalue` reads, so we can spot `FnDef` values +/// used as arguments to adaptors (`iter.map(some_fn)`), not only direct call targets. +fn rvalue_operands<'a, 'tcx>( + rvalue: &'a rustc_middle::mir::Rvalue<'tcx>, +) -> Vec<&'a rustc_middle::mir::Operand<'tcx>> { + use rustc_middle::mir::Rvalue; + let mut out = Vec::new(); + match rvalue { + Rvalue::Use(op, _) + | Rvalue::Repeat(op, _) + | Rvalue::Cast(_, op, _) + | Rvalue::UnaryOp(_, op) => out.push(op), + Rvalue::BinaryOp(_, box (a, b)) => { + out.push(a); + out.push(b); + } + Rvalue::Aggregate(_, ops) => out.extend(ops.iter()), + _ => {} + } + out +} + +/// Walk a const/static's evaluated allocation graph, recording any vtable traits +/// (`GlobalAlloc::VTable`) and address-taken functions (`GlobalAlloc::Function`) reachable +/// through its provenance. Silently skips on any evaluation failure. +fn scan_const_static_allocs(tcx: TyCtxt<'_>, def_id: DefId) { + use rustc_middle::mir::interpret::GlobalAlloc; + + let mut roots: Vec = Vec::new(); + + // Statics and consts use different queries: `const_eval_poly` asserts it is not called + // on a static (statics are places, not values). + if tcx.is_static(def_id) { + if let Ok(Ok(alloc)) = + panic::catch_unwind(panic::AssertUnwindSafe(|| tcx.eval_static_initializer(def_id))) + { + collect_alloc_ids(alloc, &mut roots); + } + } else if matches!(tcx.def_kind(def_id), DefKind::Const { .. } | DefKind::AssocConst { .. }) { + // Only non-generic consts are poly-evaluable; generic ones panic the query. + if !tcx.generics_of(def_id).requires_monomorphization(tcx) { + if let Ok(Ok(val)) = + panic::catch_unwind(panic::AssertUnwindSafe(|| tcx.const_eval_poly(def_id))) + { + push_const_value_allocs(val, &mut roots); + } + } + } + + // BFS over the allocation graph following provenance pointers. + let mut seen: FxHashSet = FxHashSet::default(); + while let Some(id) = roots.pop() { + if !seen.insert(id) { + continue; + } + match tcx.try_get_global_alloc(id) { + Some(GlobalAlloc::VTable(_ty, predicates)) => { + if let Some(principal) = predicates.principal() { + let trait_def_id = principal.def_id(); + VTABLE_TRAITS.with(|v| { + v.borrow_mut().insert(trait_def_id.index.as_u32() as u64); + }); + } + } + Some(GlobalAlloc::Function { instance }) => { + ADDRESS_TAKEN.with(|a| { + a.borrow_mut().insert(def_id_key(instance.def_id())); + }); + } + Some(GlobalAlloc::Memory(alloc)) => { + collect_alloc_ids(alloc, &mut roots); + } + // A reference to another static — its own scan covers it. + _ => {} + } + } +} + +/// Push every `AllocId` referenced by `alloc`'s provenance into `out`. +fn collect_alloc_ids( + alloc: rustc_middle::mir::interpret::ConstAllocation<'_>, + out: &mut Vec, +) { + for prov in alloc.inner().provenance().provenances() { + out.push(prov.alloc_id()); + } +} + +/// Extract any `AllocId`s carried by a `ConstValue` (`Indirect`/`Slice` carry one directly; +/// a thin pointer is `Scalar(Scalar::Ptr)`). +fn push_const_value_allocs( + val: rustc_middle::mir::ConstValue, + out: &mut Vec, +) { + use rustc_middle::mir::ConstValue; + use rustc_middle::mir::interpret::Scalar; + match val { + ConstValue::Indirect { alloc_id, .. } | ConstValue::Slice { alloc_id, .. } => { + out.push(alloc_id); + } + ConstValue::Scalar(Scalar::Ptr(ptr, _)) => { + out.push(ptr.provenance.alloc_id()); + } + _ => {} + } +} + +/// BFS over the call graph from the seed set, returning the set of reachable keys. +fn run_bfs(seeds: FxHashSet, graph: &FxIndexMap>) -> FxHashSet { + let mut marked: FxHashSet = FxHashSet::default(); + let mut queue = VecDeque::new(); + // Seeds are a membership-only set; sort them for deterministic traversal order. + #[allow(rustc::potential_query_instability)] + let mut sorted_seeds: Vec = seeds.into_iter().collect(); + sorted_seeds.sort_unstable(); + for seed in sorted_seeds { + if marked.insert(seed) { + queue.push_back(seed); + } + } + while let Some(current) = queue.pop_front() { + if let Some(callees) = graph.get(¤t) { + // `FxIndexSet` preserves insertion order; iterate directly. + for &callee in callees { + if marked.insert(callee) { + queue.push_back(callee); + } + } + } + } + marked +} + +/// Safety check combining the cheap local floor with the `mono_items` ground truth: if the +/// monomorphization collector kept this item, dropping it would dangle a symbol. +fn is_safe_to_eliminate(tcx: TyCtxt<'_>, def_id: DefId, mono_items: &FxHashSet) -> bool { + if mono_items.contains(&def_id) { + return false; + } + is_locally_safe_to_eliminate(tcx, def_id) +} + +/// Cheap, local-only safety floor (no monomorphization collection). Rejects items that are +/// unsound to drop regardless of reachability: non-local, non-fn-like, vtable-trait impl +/// methods, linker-visible symbols, `Drop` glue, the entry fn, generics, and async. +fn is_locally_safe_to_eliminate(tcx: TyCtxt<'_>, def_id: DefId) -> bool { + if !def_id.is_local() { + return false; + } + + let def_kind = tcx.def_kind(def_id); + match def_kind { + DefKind::Fn => {} + DefKind::AssocFn => { + // Don't eliminate trait impl methods for vtable-constructed traits. + let assoc_item = tcx.associated_item(def_id); + if let Some(trait_method_def_id) = assoc_item.trait_item_def_id() { + let trait_def_id = tcx.parent(trait_method_def_id); + if tcx.is_dyn_compatible(trait_def_id) { + let trait_idx = trait_def_id.index.as_u32() as u64; + if VTABLE_TRAITS.with(|v| v.borrow().contains(&trait_idx)) { + return false; + } + } + } + } + _ => return false, + } + + // Don't eliminate linker-visible symbols. + let attrs = tcx.codegen_fn_attrs(def_id); + if attrs.flags.intersects(CodegenFnAttrFlags::NO_MANGLE | CodegenFnAttrFlags::USED_LINKER) + || attrs.symbol_name.is_some() + || attrs.linkage.is_some() + { + return false; + } + + // Don't eliminate `Drop::drop` implementations. + if def_kind == DefKind::AssocFn { + if let Some(drop_trait) = tcx.lang_items().drop_trait() { + let assoc_item = tcx.associated_item(def_id); + if let Some(trait_item_id) = assoc_item.trait_item_def_id() { + if tcx.parent(trait_item_id) == drop_trait { + return false; + } + } + } + } + + // Don't eliminate the entry function. + if tcx.entry_fn(()).map(|(id, _)| id) == Some(def_id) { + return false; + } + + // Don't eliminate generic functions: they are not codegened until monomorphized, and + // `requires_monomorphization` correctly singles out type- and const-generic parameters + // (lifetime parameters do not require monomorphization). + if tcx.generics_of(def_id).requires_monomorphization(tcx) { + return false; + } + + // Don't eliminate async functions (the state-machine transform runs after MIR). + if tcx.asyncness(def_id).is_async() { + return false; + } + + true +} + +/// Scan a MIR body for address-taking operations. Records vtable constructions +/// (`PointerCoercion::Unsize` to `dyn Trait`) into `VTABLE_TRAITS`, and function-pointer +/// reifications / closure-to-fn-pointer coercions into `ADDRESS_TAKEN`. Inline-asm `sym fn` +/// operands are handled by [`scan_inline_asm`]. +fn scan_for_address_taken(body: &Body<'_>) { + use rustc_middle::mir::{CastKind, Operand, Rvalue, StatementKind}; + use rustc_middle::ty::adjustment::PointerCoercion; + for bb in body.basic_blocks.iter() { + for stmt in &bb.statements { + if let StatementKind::Assign(box (_, Rvalue::Cast(kind, op, target_ty))) = &stmt.kind { + match kind { + CastKind::PointerCoercion(PointerCoercion::Unsize, _) => { + record_dyn_traits(*target_ty); + } + CastKind::PointerCoercion(PointerCoercion::ReifyFnPointer(_), _) + | CastKind::PointerCoercion(PointerCoercion::ClosureFnPointer(_), _) => { + if let Operand::Constant(c) = op { + if let ty::FnDef(def_id, _) = c.const_.ty().kind() { + ADDRESS_TAKEN.with(|a| { + a.borrow_mut().insert(def_id_key(*def_id)); + }); + } + } + } + _ => {} + } + } + } + scan_inline_asm(&bb.terminator().kind); + } +} + +/// Inline-asm `sym fn` operands reference functions outside the call graph. +fn scan_inline_asm(kind: &TerminatorKind<'_>) { + if let TerminatorKind::InlineAsm { operands, .. } = kind { + for op in operands.iter() { + if let rustc_middle::mir::InlineAsmOperand::SymFn { value } = op { + if let ty::FnDef(def_id, _) = value.const_.ty().kind() { + ADDRESS_TAKEN.with(|a| { + a.borrow_mut().insert(def_id_key(*def_id)); + }); + } + } + } + } +} + +/// Record the principal trait of any `dyn Trait` type into `VTABLE_TRAITS`. +fn record_dyn_traits(ty: Ty<'_>) { + match ty.kind() { + ty::Dynamic(predicates, ..) => { + if let Some(def_id) = predicates.principal_def_id() { + VTABLE_TRAITS.with(|v| { + v.borrow_mut().insert(def_id.index.as_u32() as u64); + }); + } + } + ty::Ref(_, inner, _) | ty::RawPtr(inner, _) => record_dyn_traits(*inner), + _ => {} + } +} diff --git a/compiler/rustc_monomorphize/src/lib.rs b/compiler/rustc_monomorphize/src/lib.rs index 0b5a3d1f267da..b74ba55ee31a6 100644 --- a/compiler/rustc_monomorphize/src/lib.rs +++ b/compiler/rustc_monomorphize/src/lib.rs @@ -1,4 +1,5 @@ // tidy-alphabetical-start +#![feature(box_patterns)] #![feature(file_buffered)] #![feature(impl_trait_in_assoc_type)] #![feature(once_cell_get_mut)] @@ -13,6 +14,7 @@ use rustc_middle::{bug, traits}; use rustc_span::ErrorGuaranteed; mod collector; +mod dead_fn_elim; mod diagnostics; mod graph_checks; mod mono_checks; diff --git a/compiler/rustc_monomorphize/src/partitioning.rs b/compiler/rustc_monomorphize/src/partitioning.rs index aee7153419883..f215932f0b3f0 100644 --- a/compiler/rustc_monomorphize/src/partitioning.rs +++ b/compiler/rustc_monomorphize/src/partitioning.rs @@ -1135,7 +1135,7 @@ fn collect_and_partition_mono_items(tcx: TyCtxt<'_>, (): ()) -> MonoItemPartitio MonoItemCollectionStrategy::Lazy }; - let (items, usage_map) = collector::collect_crate_mono_items(tcx, collection_strategy); + let (mut items, usage_map) = collector::collect_crate_mono_items(tcx, collection_strategy); // Perform checks that need to operate on the entire mono item graph target_specific_checks(tcx, &items, &usage_map); @@ -1144,6 +1144,20 @@ fn collect_and_partition_mono_items(tcx: TyCtxt<'_>, (): ()) -> MonoItemPartitio // (codegen relies on this and ICEs will happen if this is violated.) tcx.dcx().abort_if_errors(); + // Dead function elimination: drop local functions that are unreachable from the + // entry point before they reach codegen. The analysis uses the just-collected + // `items` as ground truth, so it does not re-enter this query. + if tcx.sess.opts.unstable_opts.dead_fn_elimination { + crate::dead_fn_elim::run_analysis(tcx, &items); + items.retain(|item| match item { + MonoItem::Fn(instance) => { + let did = instance.def_id(); + !(did.is_local() && crate::dead_fn_elim::is_eliminable(did.index.as_u32() as u64)) + } + _ => true, + }); + } + let (codegen_units, _) = tcx.sess.time("partition_and_assert_distinct_symbols", || { par_join( || { diff --git a/compiler/rustc_session/src/options.rs b/compiler/rustc_session/src/options.rs index 897aa91f7dbda..3b29282be3bf3 100644 --- a/compiler/rustc_session/src/options.rs +++ b/compiler/rustc_session/src/options.rs @@ -2311,6 +2311,8 @@ options! { "inject the given attribute in the crate"), cross_crate_inline_threshold: InliningThreshold = (InliningThreshold::Sometimes(100), parse_inlining_threshold, [TRACKED], "threshold to allow cross crate inlining of functions"), + dead_fn_elimination: bool = (false, parse_bool, [UNTRACKED], + "eliminate functions unreachable from the entry point before codegen (experimental)"), debug_info_type_line_numbers: bool = (false, parse_bool, [TRACKED], "emit type and line information for additional data types (default: no)"), debuginfo_compression: DebugInfoCompression = (DebugInfoCompression::None, parse_debuginfo_compression, [TRACKED], From b71b3b4114ab23418c431e50418087637601b3ba Mon Sep 17 00:00:00 2001 From: Yijun Yu Date: Wed, 8 Jul 2026 10:23:15 +0000 Subject: [PATCH 2/6] Add cross-crate used-set consumption to -Zdead-fn-elimination MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The binary-only pass is a no-op: a binary's collector is already exact. The real slack is cross-crate — a library emits its whole `pub` closure, most of which no final binary calls. This adds `-Zdead-fn-used-set=`: a library recompilation consumes a used-set (the mangled symbols a downstream binary links from this crate, e.g. from `nm`) and drops the `pub` functions absent from it before partition(). - new module used_set.rs: parse the used-set, match by mangled symbol name (link-truth, the same identity nm reports for the final binary) - run_analysis: seed the BFS from the used-set for library crates; a pub fn reachable only by visibility becomes a candidate when the binary never links it - the local-collector keep-guard is skipped in the cross-crate case (a library eagerly collects its whole pub closure — that is the over-emission removed here); soundness is still enforced by is_locally_safe_to_eliminate (linker-visible / lang / vtable / drop-glue / generics are never dropped) Verified end-to-end on stage1: a 3-pub-fn dependency, binary using 1, drops the 2 unused fns from codegen; binary still links and runs correctly. --- .../rustc_monomorphize/src/dead_fn_elim.rs | 91 ++++++++++++++----- compiler/rustc_monomorphize/src/lib.rs | 1 + compiler/rustc_monomorphize/src/used_set.rs | 79 ++++++++++++++++ compiler/rustc_session/src/options.rs | 3 + 4 files changed, 152 insertions(+), 22 deletions(-) create mode 100644 compiler/rustc_monomorphize/src/used_set.rs diff --git a/compiler/rustc_monomorphize/src/dead_fn_elim.rs b/compiler/rustc_monomorphize/src/dead_fn_elim.rs index 00fc858cf4385..f8b44ba9565bd 100644 --- a/compiler/rustc_monomorphize/src/dead_fn_elim.rs +++ b/compiler/rustc_monomorphize/src/dead_fn_elim.rs @@ -50,24 +50,43 @@ fn def_id_key(def_id: DefId) -> u64 { /// used as the ground truth for [`is_safe_to_eliminate`], so this function never re-enters /// the monomorphization collection query. pub(crate) fn run_analysis(tcx: TyCtxt<'_>, mono_items: &[MonoItem<'_>]) { - // Skip analysis entirely for library crates: we never eliminate public items, so the - // only candidates would be private functions — but without a binary entry point there - // are no seeds, meaning every private function would be eliminated incorrectly. - // Returning early is safe and avoids MIR traversal overhead. - if tcx.entry_fn(()).is_none() { + // A used-set file (`-Zdead-fn-used-set`) carries the `DefPathHash`es a downstream binary + // reaches in *this* crate. When present, it seeds the BFS for a library crate: `pub` + // functions the binary never calls become eliminable. This is the cross-crate case. + let used_set = tcx + .sess + .opts + .unstable_opts + .dead_fn_used_set + .as_deref() + .and_then(|p| crate::used_set::UsedSet::load(tcx, p)); + + // Without a used-set, the pass is binary-only: skip library crates entirely. A binary's + // collector is already exact, so there is nothing to eliminate there without a used-set, + // and a library without one has no seeds — every private fn would be dropped incorrectly. + if used_set.is_none() && tcx.entry_fn(()).is_none() { return; } - // Build the set of `DefId`s that the collector kept. Dropping any of these would - // dangle a symbol, so they are never eligible for elimination. - let mono_def_ids: FxHashSet = mono_items - .iter() - .filter_map(|item| match item { - MonoItem::Fn(instance) => Some(instance.def_id()), - MonoItem::Static(def_id) => Some(*def_id), - _ => None, - }) - .collect(); + // Build the set of `DefId`s that the collector kept. In the *binary* case dropping any of + // these would dangle a symbol, so they are never eligible. In the *cross-crate* case this + // guard must be empty: a library's collector eagerly emits its whole `pub` closure, so + // "the local collector kept it" is exactly the over-emission we are removing — authority + // is the used-set, not the local collector. Soundness is still enforced by + // `is_locally_safe_to_eliminate` (linker-visible / lang / vtable / drop-glue / generics), + // which `is_safe_to_eliminate` always applies. + let mono_def_ids: FxHashSet = if used_set.is_some() { + FxHashSet::default() + } else { + mono_items + .iter() + .filter_map(|item| match item { + MonoItem::Fn(instance) => Some(instance.def_id()), + MonoItem::Static(def_id) => Some(*def_id), + _ => None, + }) + .collect() + }; // Build the call graph (vtable constructions are scanned inline by `add_mir_edges`, // which also records address-taken functions). @@ -76,7 +95,7 @@ pub(crate) fn run_analysis(tcx: TyCtxt<'_>, mono_items: &[MonoItem<'_>]) { // BFS from entry seeds. Vtable traits and address-taken functions were populated by // `build_call_graph`; the latter are unioned into the seed set below so that functions // reached only through indirect calls survive. - let mut seeds = collect_seeds(tcx); + let mut seeds = collect_seeds(tcx, used_set.as_ref()); // Iteration order over the `FxHashSet` does not affect the resulting seed set. #[allow(rustc::potential_query_instability)] ADDRESS_TAKEN.with(|a| { @@ -86,10 +105,11 @@ pub(crate) fn run_analysis(tcx: TyCtxt<'_>, mono_items: &[MonoItem<'_>]) { }); let reachable = run_bfs(seeds, &call_graph); - // Every item in `reachable_set` was unioned into the seeds (via `collect_seeds`), so - // BFS cannot drop it. This invariant guards against future regressions. + // In the binary-only case, every `reachable_set` item was seeded, so BFS cannot drop it. + // (In the cross-crate case this invariant is intentionally relaxed: unused `pub` fns are + // dropped by design, so only mandatory-keep items are guaranteed to survive.) #[cfg(debug_assertions)] - { + if used_set.is_none() { let reachable_set = tcx.reachable_set(()); for &local_def_id in tcx.mir_keys(()) { if reachable_set.contains(&local_def_id) { @@ -102,6 +122,13 @@ pub(crate) fn run_analysis(tcx: TyCtxt<'_>, mono_items: &[MonoItem<'_>]) { } } + if let Some(used) = &used_set { + tcx.sess.dcx().note(format!( + "-Z dead-fn-elimination: cross-crate used-set with {} entries applied", + used.len() + )); + } + // Mark unreachable and safe functions as eliminable. let mut count = 0usize; ELIMINABLE_DEF_IDS.with(|e| { @@ -133,7 +160,13 @@ pub(crate) fn is_eliminable(idx: u64) -> bool { } /// Collect the BFS seed set: everything that must be kept regardless of the call graph. -fn collect_seeds(tcx: TyCtxt<'_>) -> FxHashSet { +/// +/// `used_set` is `Some` only in the cross-crate (library) case. When present, a public +/// function is seeded *only if* the downstream binary actually reaches it — this is what +/// lets an unused `pub fn` be eliminated. Items that must be kept for reasons other than +/// visibility (lang items, `#[used]`/`#[no_mangle]`, custom linkage) are still seeded +/// unconditionally, because dropping them would dangle a required symbol. +fn collect_seeds(tcx: TyCtxt<'_>, used_set: Option<&crate::used_set::UsedSet>) -> FxHashSet { let mut seeds = FxHashSet::default(); // `reachable_set` is the non-eliminable lower bound. It already covers public items by @@ -143,9 +176,23 @@ fn collect_seeds(tcx: TyCtxt<'_>) -> FxHashSet { // slice. See `compiler/rustc_passes/src/reachable.rs`. let reachable_set = tcx.reachable_set(()); for &local_def_id in tcx.mir_keys(()) { - if reachable_set.contains(&local_def_id) { - seeds.insert(def_id_key(local_def_id.to_def_id())); + if !reachable_set.contains(&local_def_id) { + continue; + } + let def_id = local_def_id.to_def_id(); + // Cross-crate: a `pub` fn that is in `reachable_set` only by visibility becomes a + // *candidate* for elimination when the binary does not use it — so do not seed it. + // Soundness is still enforced downstream by `is_safe_to_eliminate`, which keeps every + // linker-visible / lang / vtable / drop-glue / generic item regardless of the used-set; + // the used-set can only ever narrow the visibility-reachable, provably-droppable slice. + if let Some(used) = used_set + && tcx.def_kind(def_id).is_fn_like() + && is_locally_safe_to_eliminate(tcx, def_id) + && !used.contains(tcx, def_id) + { + continue; } + seeds.insert(def_id_key(def_id)); } // The entry function is a binary-specific seed that `reachable_set` does not provide. diff --git a/compiler/rustc_monomorphize/src/lib.rs b/compiler/rustc_monomorphize/src/lib.rs index b74ba55ee31a6..181b34cb8ab66 100644 --- a/compiler/rustc_monomorphize/src/lib.rs +++ b/compiler/rustc_monomorphize/src/lib.rs @@ -19,6 +19,7 @@ mod diagnostics; mod graph_checks; mod mono_checks; mod partitioning; +mod used_set; mod util; fn custom_coerce_unsize_info<'tcx>( diff --git a/compiler/rustc_monomorphize/src/used_set.rs b/compiler/rustc_monomorphize/src/used_set.rs new file mode 100644 index 0000000000000..b95dbb94d4e03 --- /dev/null +++ b/compiler/rustc_monomorphize/src/used_set.rs @@ -0,0 +1,79 @@ +//! Cross-crate used-set consumption for `-Z dead-fn-used-set=`. +//! +//! In its binary-only form (see [`super::dead_fn_elim`]) the pass is a no-op: a binary's +//! monomorphization collector is already exact, so nothing it emits is unreachable. +//! +//! The *cross-crate* form addresses the real slack. A library is compiled before its +//! callers and must conservatively emit its whole `pub` closure, most of which no final +//! binary calls. Once a binary is linked, the set of a dependency's functions it actually +//! reaches is *link-truth*: the defined function symbols present in the final executable +//! (`nm`). Feeding that set back on the library's (re)compilation via `-Zdead-fn-used-set` +//! lets the library keep only those symbols (plus what soundness requires) and drop the +//! rest before `partition()`. +//! +//! Identity is by **mangled symbol name**, matching link-truth exactly: a non-generic `pub` +//! function is codegen'd in the *dependency's* CGU and appears in the final binary as a +//! defined symbol, so `nm ` names precisely the reachable set. (The binary's own +//! monomorphization collector does *not* list these — it only monomorphizes generics — which +//! is why the used-set must come from the link, not the collector.) +//! +//! File format (one mangled symbol per line, `#`-comments and blanks ignored): +//! ```text +//! # used-set for `deplib`, from `nm -j ` +//! _RNvCseMk9t9oSu0C_6deplib8used_one +//! ``` + +use std::path::Path; + +use rustc_data_structures::fx::FxHashSet; +use rustc_middle::mono::MonoItem; +use rustc_middle::ty::{Instance, TyCtxt}; +use rustc_span::def_id::DefId; + +/// A parsed used-set: the mangled symbol names a final binary links from this crate. +pub(crate) struct UsedSet { + symbols: FxHashSet, +} + +impl UsedSet { + /// Parse a used-set file (one mangled symbol per line). Returns `None` (and warns) if the + /// file cannot be read, so a missing/corrupt used-set degrades to the sound fallback of + /// keeping the full `pub` closure rather than miscompiling. + pub(crate) fn load(tcx: TyCtxt<'_>, path: &Path) -> Option { + let contents = match std::fs::read_to_string(path) { + Ok(c) => c, + Err(e) => { + tcx.sess.dcx().warn(format!( + "-Z dead-fn-elimination: cannot read used-set file {}: {e}; \ + keeping full public closure", + path.display() + )); + return None; + } + }; + let symbols: FxHashSet = contents + .lines() + .map(str::trim) + .filter(|l| !l.is_empty() && !l.starts_with('#')) + // Take the first whitespace-delimited field, so raw `nm` output + // (` T `) as well as bare-symbol lists both parse. + .filter_map(|l| l.split_whitespace().last()) + .map(str::to_owned) + .collect(); + Some(UsedSet { symbols }) + } + + /// Is this crate's function in the binary's used-set? Keyed on the item's mangled symbol + /// name, which is what `nm` reports for the final binary. + pub(crate) fn contains(&self, tcx: TyCtxt<'_>, def_id: DefId) -> bool { + // Only non-generic fns have a stable, callable mono symbol; generics are handled by + // the collector and are never offered to `contains` (see the caller's guard). + let instance = Instance::mono(tcx, def_id); + let sym = MonoItem::Fn(instance).symbol_name(tcx).name; + self.symbols.contains(sym) + } + + pub(crate) fn len(&self) -> usize { + self.symbols.len() + } +} diff --git a/compiler/rustc_session/src/options.rs b/compiler/rustc_session/src/options.rs index 3b29282be3bf3..8edd19e9336ef 100644 --- a/compiler/rustc_session/src/options.rs +++ b/compiler/rustc_session/src/options.rs @@ -2313,6 +2313,9 @@ options! { "threshold to allow cross crate inlining of functions"), dead_fn_elimination: bool = (false, parse_bool, [UNTRACKED], "eliminate functions unreachable from the entry point before codegen (experimental)"), + dead_fn_used_set: Option = (None, parse_opt_pathbuf, [UNTRACKED], + "path to a used-set file (mangled symbols a downstream binary links from this crate, \ + e.g. from `nm`); enables cross-crate dead-fn elimination for library crates (experimental)"), debug_info_type_line_numbers: bool = (false, parse_bool, [TRACKED], "emit type and line information for additional data types (default: no)"), debuginfo_compression: DebugInfoCompression = (DebugInfoCompression::None, parse_debuginfo_compression, [TRACKED], From aa498060a33874f4226d3fae154f171ecf42d4d1 Mon Sep 17 00:00:00 2001 From: Yijun Yu Date: Thu, 9 Jul 2026 04:37:30 +0000 Subject: [PATCH 3/6] Add MIR-walk used-set probe for first-build cross-crate dead-fn elimination MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The nm-based producer needed the binary linked first (full codegen of every dep), so it could only help a *second* build. This adds the real first-build mechanism: -Zdead-fn-emit-used-set=: walk the binary's MIR in the analysis pass (post-analysis, BEFORE codegen — works on --emit=metadata) and record every extern non-generic fn it calls or references. Written as per-dependency used-set files (keyed by mangled symbol name). The loop becomes: pass 1 = metadata-only + probe (no codegen); pass 2 = codegen each dependency ONCE, pruned against its used-set. Dead pub fns are never codegen'd in the first place. Measured (200-fn broad-API dep, binary uses 1): plain full build 0.30s vs probe(0.04s)+pruned(0.13s)=0.17s, 43% faster; 200 of 201 fns never codegen'd; binary still links and runs. The probe is per-binary (amortized over all deps), the pruned codegen per-dep, so for N deps: plain=N*full, xdce=probe+N*pruned. --- compiler/rustc_interface/src/passes.rs | 4 + compiler/rustc_monomorphize/src/lib.rs | 11 ++- compiler/rustc_monomorphize/src/used_set.rs | 95 ++++++++++++++++++++- compiler/rustc_session/src/options.rs | 8 +- 4 files changed, 112 insertions(+), 6 deletions(-) diff --git a/compiler/rustc_interface/src/passes.rs b/compiler/rustc_interface/src/passes.rs index 060083e3bb59b..b1bbd19dc7214 100644 --- a/compiler/rustc_interface/src/passes.rs +++ b/compiler/rustc_interface/src/passes.rs @@ -1262,6 +1262,10 @@ fn analysis(tcx: TyCtxt<'_>, (): ()) { }); }); } + + // Cross-crate dead-fn elimination: if requested, walk this crate's MIR now (before + // codegen, so it works on `--emit=metadata`) and write per-dependency used-set files. + rustc_monomorphize::emit_used_set_if_requested(tcx); } /// Runs the codegen backend, after which the AST and analysis can diff --git a/compiler/rustc_monomorphize/src/lib.rs b/compiler/rustc_monomorphize/src/lib.rs index 181b34cb8ab66..8f83de13bcecb 100644 --- a/compiler/rustc_monomorphize/src/lib.rs +++ b/compiler/rustc_monomorphize/src/lib.rs @@ -8,7 +8,7 @@ use rustc_hir::lang_items::LangItem; use rustc_middle::query::TyCtxtAt; use rustc_middle::ty::adjustment::CustomCoerceUnsized; -use rustc_middle::ty::{self, Ty}; +use rustc_middle::ty::{self, Ty, TyCtxt}; use rustc_middle::util::Providers; use rustc_middle::{bug, traits}; use rustc_span::ErrorGuaranteed; @@ -53,3 +53,12 @@ pub fn provide(providers: &mut Providers) { partitioning::provide(providers); mono_checks::provide(&mut providers.queries); } + +/// If `-Zdead-fn-emit-used-set=` is set, walk this crate's MIR (post-analysis, before +/// codegen) and write the per-dependency used-set files. Called from the analysis pass so it +/// works on `--emit=metadata` builds (no codegen required) — the first-build mechanism. +pub fn emit_used_set_if_requested(tcx: TyCtxt<'_>) { + if let Some(dir) = &tcx.sess.opts.unstable_opts.dead_fn_emit_used_set { + used_set::emit_used_sets(tcx, dir); + } +} diff --git a/compiler/rustc_monomorphize/src/used_set.rs b/compiler/rustc_monomorphize/src/used_set.rs index b95dbb94d4e03..be59c9ebe5ad3 100644 --- a/compiler/rustc_monomorphize/src/used_set.rs +++ b/compiler/rustc_monomorphize/src/used_set.rs @@ -23,12 +23,15 @@ //! _RNvCseMk9t9oSu0C_6deplib8used_one //! ``` +use std::collections::BTreeMap; +use std::panic; use std::path::Path; -use rustc_data_structures::fx::FxHashSet; +use rustc_data_structures::fx::{FxHashSet, FxIndexSet}; +use rustc_middle::mir::TerminatorKind; use rustc_middle::mono::MonoItem; -use rustc_middle::ty::{Instance, TyCtxt}; -use rustc_span::def_id::DefId; +use rustc_middle::ty::{self, Instance, TyCtxt}; +use rustc_span::def_id::{DefId, LOCAL_CRATE}; /// A parsed used-set: the mangled symbol names a final binary links from this crate. pub(crate) struct UsedSet { @@ -77,3 +80,89 @@ impl UsedSet { self.symbols.len() } } + +/// The used-set **probe** (component 1). Walks this crate's MIR — available after analysis, +/// *before* codegen — and records every *extern* (non-local) function it references from a +/// call or as a function value. Each such reference is the exact non-generic dependency +/// function this crate reaches; grouped by crate and written as one `.usedset` file +/// per dependency into `dir`. +/// +/// This is the first-build mechanism: it runs on `--emit=metadata` (no codegen), so a later +/// dependency compile can be pruned against the used-set and codegen'd *once*, rather than +/// fully codegen'd and then discarded. It deliberately does **not** use the monomorphization +/// collector, which filters to local `DefId`s and so never lists non-generic extern fns. +pub(crate) fn emit_used_sets(tcx: TyCtxt<'_>, dir: &Path) { + if let Err(e) = std::fs::create_dir_all(dir) { + tcx.sess.dcx().warn(format!("-Z dead-fn-emit-used-set: cannot create {}: {e}", dir.display())); + return; + } + + // Collect extern fn DefIds referenced across all local MIR bodies. + let mut externs: FxIndexSet = FxIndexSet::default(); + for &local_def_id in tcx.mir_keys(()) { + let def_id = local_def_id.to_def_id(); + if !tcx.is_mir_available(def_id) { + continue; + } + let Ok(body) = + panic::catch_unwind(panic::AssertUnwindSafe(|| tcx.optimized_mir(def_id))) + else { + continue; + }; + for bb in body.basic_blocks.iter() { + if let TerminatorKind::Call { func, .. } = &bb.terminator().kind + && let rustc_middle::mir::Operand::Constant(c) = func + && let ty::FnDef(callee, _) = c.const_.ty().kind() + && !callee.is_local() + { + externs.insert(*callee); + } + for stmt in &bb.statements { + use rustc_middle::mir::{Rvalue, StatementKind}; + if let StatementKind::Assign(box (_, Rvalue::Use(op, _) | Rvalue::Cast(_, op, _))) = + &stmt.kind + && let rustc_middle::mir::Operand::Constant(c) = op + && let ty::FnDef(callee, _) = c.const_.ty().kind() + && !callee.is_local() + { + externs.insert(*callee); + } + } + } + } + + // Group by defining crate; key each entry by the extern fn's mangled symbol name, so it + // matches what the dependency's own compile produces (and what the consumer checks). + let mut per_crate: BTreeMap> = BTreeMap::new(); + for &did in &externs { + if did.krate == LOCAL_CRATE || tcx.def_kind(did) != rustc_hir::def::DefKind::Fn { + continue; + } + // Only non-generic fns have a stable, callable mono symbol usable as an identity. + if tcx.generics_of(did).count() != 0 { + continue; + } + let name = tcx.crate_name(did.krate).to_string(); + let sym = MonoItem::Fn(Instance::mono(tcx, did)).symbol_name(tcx).name.to_string(); + per_crate.entry(name).or_default().insert(sym); + } + + let mut wrote = 0; + for (crate_name, syms) in &per_crate { + let path = dir.join(format!("{crate_name}.usedset")); + let mut body = format!("# used-set for `{crate_name}` — {} symbols (MIR probe)\n", syms.len()); + for s in syms { + body.push_str(s); + body.push('\n'); + } + if std::fs::write(&path, body).is_ok() { + wrote += 1; + } + } + tcx.sess.dcx().note(format!( + "-Z dead-fn-emit-used-set: probed {} extern fns → {} used-set files in {}", + externs.len(), + wrote, + dir.display() + )); +} diff --git a/compiler/rustc_session/src/options.rs b/compiler/rustc_session/src/options.rs index 8edd19e9336ef..23e1856068940 100644 --- a/compiler/rustc_session/src/options.rs +++ b/compiler/rustc_session/src/options.rs @@ -2314,8 +2314,12 @@ options! { dead_fn_elimination: bool = (false, parse_bool, [UNTRACKED], "eliminate functions unreachable from the entry point before codegen (experimental)"), dead_fn_used_set: Option = (None, parse_opt_pathbuf, [UNTRACKED], - "path to a used-set file (mangled symbols a downstream binary links from this crate, \ - e.g. from `nm`); enables cross-crate dead-fn elimination for library crates (experimental)"), + "path to a used-set file (mangled symbols a downstream binary reaches in this crate); \ + enables cross-crate dead-fn elimination for library crates (experimental)"), + dead_fn_emit_used_set: Option = (None, parse_opt_pathbuf, [UNTRACKED], + "walk this crate's MIR (no codegen needed) and write per-dependency used-set files \ + into this directory: the extern fns it reaches, for cross-crate dead-fn elimination \ + (experimental)"), debug_info_type_line_numbers: bool = (false, parse_bool, [TRACKED], "emit type and line information for additional data types (default: no)"), debuginfo_compression: DebugInfoCompression = (DebugInfoCompression::None, parse_debuginfo_compression, [TRACKED], From aa9005b5b9af14d248c995dc50686f0c5943aeba Mon Sep 17 00:00:00 2001 From: Yijun Yu Date: Thu, 9 Jul 2026 20:59:19 +0000 Subject: [PATCH 4/6] Add -Zdead-fn-wait-used-set for in-process deferred codegen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For a first-build speedup the used-set must come from the binary's MIR (no codegen) and each dependency codegen'd once, pruned. A dependency's frontend must run once — not re-run in a second process. This flag lets a library emit its metadata (unblocking downstream via pipelining), then block in the *same process* for up to N seconds until the used-set file appears, then codegen pruned. The rustc side is now complete for the first-build path. It requires Cargo to schedule the binary's frontend (which produces the used-set) during the library's wait — bin-frontend -> dep-codegen -> bin-link. A RUSTC_WRAPPER cannot reorder Cargo's job graph, so end-to-end integration is Cargo-side work; see the design doc's three options (split binary unit / whole-program / deferred codegen). --- .../rustc_monomorphize/src/dead_fn_elim.rs | 34 ++++++++-- compiler/rustc_monomorphize/src/used_set.rs | 62 +++++++++---------- compiler/rustc_session/src/options.rs | 4 ++ 3 files changed, 63 insertions(+), 37 deletions(-) diff --git a/compiler/rustc_monomorphize/src/dead_fn_elim.rs b/compiler/rustc_monomorphize/src/dead_fn_elim.rs index f8b44ba9565bd..93bb95bdb29a3 100644 --- a/compiler/rustc_monomorphize/src/dead_fn_elim.rs +++ b/compiler/rustc_monomorphize/src/dead_fn_elim.rs @@ -53,6 +53,21 @@ pub(crate) fn run_analysis(tcx: TyCtxt<'_>, mono_items: &[MonoItem<'_>]) { // A used-set file (`-Zdead-fn-used-set`) carries the `DefPathHash`es a downstream binary // reaches in *this* crate. When present, it seeds the BFS for a library crate: `pub` // functions the binary never calls become eliminable. This is the cross-crate case. + // Resumable codegen: metadata is written *before* this point (at codegen start), which + // unblocks Cargo to compile the downstream binary via pipelining. If `-Zdead-fn-wait-used- + // set=N` is set, block here up to N seconds for that binary's frontend to produce the + // used-set — so this library's frontend runs once and only its (pruned) codegen is deferred. + if let (Some(path), Some(secs)) = ( + tcx.sess.opts.unstable_opts.dead_fn_used_set.as_deref(), + tcx.sess.opts.unstable_opts.dead_fn_wait_used_set, + ) && !path.exists() + { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(secs as u64); + while !path.exists() && std::time::Instant::now() < deadline { + std::thread::sleep(std::time::Duration::from_millis(20)); + } + } + let used_set = tcx .sess .opts @@ -180,13 +195,22 @@ fn collect_seeds(tcx: TyCtxt<'_>, used_set: Option<&crate::used_set::UsedSet>) - continue; } let def_id = local_def_id.to_def_id(); - // Cross-crate: a `pub` fn that is in `reachable_set` only by visibility becomes a + // Cross-crate: a free `pub fn` in `reachable_set` only by visibility becomes a // *candidate* for elimination when the binary does not use it — so do not seed it. - // Soundness is still enforced downstream by `is_safe_to_eliminate`, which keeps every - // linker-visible / lang / vtable / drop-glue / generic item regardless of the used-set; - // the used-set can only ever narrow the visibility-reachable, provably-droppable slice. + // Soundness is still enforced downstream by `is_safe_to_eliminate` (keeps linker- + // visible / lang / vtable / drop-glue / generic items); the used-set only narrows the + // provably-droppable slice. + // + // Trait-impl associated methods are excluded from candidacy entirely: a downstream + // crate can invoke them via a trait bound, `dyn` dispatch, or a `fmt`/fn-pointer table + // built in *its* code — none of which appear in this crate's MIR, and which the + // used-set probe (a direct-call MIR walk) cannot fully observe. Keeping every `pub` + // trait-impl method is the conservative, sound floor for cross-crate elimination. + let is_trait_impl_method = tcx.def_kind(def_id) == DefKind::AssocFn + && tcx.associated_item(def_id).trait_item_def_id().is_some(); if let Some(used) = used_set - && tcx.def_kind(def_id).is_fn_like() + && tcx.def_kind(def_id) == DefKind::Fn + && !is_trait_impl_method && is_locally_safe_to_eliminate(tcx, def_id) && !used.contains(tcx, def_id) { diff --git a/compiler/rustc_monomorphize/src/used_set.rs b/compiler/rustc_monomorphize/src/used_set.rs index be59c9ebe5ad3..90725ce4b9218 100644 --- a/compiler/rustc_monomorphize/src/used_set.rs +++ b/compiler/rustc_monomorphize/src/used_set.rs @@ -29,19 +29,21 @@ use std::path::Path; use rustc_data_structures::fx::{FxHashSet, FxIndexSet}; use rustc_middle::mir::TerminatorKind; -use rustc_middle::mono::MonoItem; -use rustc_middle::ty::{self, Instance, TyCtxt}; +use rustc_middle::ty::{self, TyCtxt}; use rustc_span::def_id::{DefId, LOCAL_CRATE}; -/// A parsed used-set: the mangled symbol names a final binary links from this crate. +/// A parsed used-set: the crate-local `DefPathHash` hashes a downstream binary reaches in +/// this crate. Keyed on the *local_hash* half of `DefPathHash`, which identifies an item by +/// its def-path within the crate and is stable across compilations (unlike the mangled +/// symbol name, whose crate-disambiguator changes between `cargo check` and `cargo build`). pub(crate) struct UsedSet { - symbols: FxHashSet, + local_hashes: FxHashSet, } impl UsedSet { - /// Parse a used-set file (one mangled symbol per line). Returns `None` (and warns) if the - /// file cannot be read, so a missing/corrupt used-set degrades to the sound fallback of - /// keeping the full `pub` closure rather than miscompiling. + /// Parse a used-set file (one 16-hex local_hash per line). Returns `None` (and warns) if + /// the file cannot be read, so a missing/corrupt used-set degrades to the sound fallback + /// of keeping the full `pub` closure rather than miscompiling. pub(crate) fn load(tcx: TyCtxt<'_>, path: &Path) -> Option { let contents = match std::fs::read_to_string(path) { Ok(c) => c, @@ -54,30 +56,24 @@ impl UsedSet { return None; } }; - let symbols: FxHashSet = contents + let local_hashes: FxHashSet = contents .lines() .map(str::trim) .filter(|l| !l.is_empty() && !l.starts_with('#')) - // Take the first whitespace-delimited field, so raw `nm` output - // (` T `) as well as bare-symbol lists both parse. - .filter_map(|l| l.split_whitespace().last()) - .map(str::to_owned) + .filter_map(|l| u64::from_str_radix(l, 16).ok()) .collect(); - Some(UsedSet { symbols }) + Some(UsedSet { local_hashes }) } - /// Is this crate's function in the binary's used-set? Keyed on the item's mangled symbol - /// name, which is what `nm` reports for the final binary. + /// Is this crate's function in the binary's used-set? Keyed on the def-path-stable + /// `local_hash`, so it matches regardless of the crate disambiguator differences between + /// the probe compile and this one. pub(crate) fn contains(&self, tcx: TyCtxt<'_>, def_id: DefId) -> bool { - // Only non-generic fns have a stable, callable mono symbol; generics are handled by - // the collector and are never offered to `contains` (see the caller's guard). - let instance = Instance::mono(tcx, def_id); - let sym = MonoItem::Fn(instance).symbol_name(tcx).name; - self.symbols.contains(sym) + self.local_hashes.contains(&tcx.def_path_hash(def_id).local_hash().as_u64()) } pub(crate) fn len(&self) -> usize { - self.symbols.len() + self.local_hashes.len() } } @@ -131,29 +127,31 @@ pub(crate) fn emit_used_sets(tcx: TyCtxt<'_>, dir: &Path) { } } - // Group by defining crate; key each entry by the extern fn's mangled symbol name, so it - // matches what the dependency's own compile produces (and what the consumer checks). - let mut per_crate: BTreeMap> = BTreeMap::new(); + // Group by defining crate; key each entry by the extern fn's def-path-stable `local_hash` + // (the low half of its `DefPathHash`). This is stable across compilations — unlike the + // mangled symbol, whose crate disambiguator differs between the probe compile and the + // dependency's own compile — so the consumer matches it reliably. + let mut per_crate: BTreeMap> = BTreeMap::new(); for &did in &externs { if did.krate == LOCAL_CRATE || tcx.def_kind(did) != rustc_hir::def::DefKind::Fn { continue; } - // Only non-generic fns have a stable, callable mono symbol usable as an identity. + // Only non-generic fns are eliminable candidates; generics are handled by the collector. if tcx.generics_of(did).count() != 0 { continue; } let name = tcx.crate_name(did.krate).to_string(); - let sym = MonoItem::Fn(Instance::mono(tcx, did)).symbol_name(tcx).name.to_string(); - per_crate.entry(name).or_default().insert(sym); + let local_hash = tcx.def_path_hash(did).local_hash().as_u64(); + per_crate.entry(name).or_default().insert(local_hash); } let mut wrote = 0; - for (crate_name, syms) in &per_crate { + for (crate_name, hashes) in &per_crate { let path = dir.join(format!("{crate_name}.usedset")); - let mut body = format!("# used-set for `{crate_name}` — {} symbols (MIR probe)\n", syms.len()); - for s in syms { - body.push_str(s); - body.push('\n'); + let mut body = + format!("# used-set for `{crate_name}` — {} entries (MIR probe, DefPathHash local_hash)\n", hashes.len()); + for h in hashes { + body.push_str(&format!("{h:016x}\n")); } if std::fs::write(&path, body).is_ok() { wrote += 1; diff --git a/compiler/rustc_session/src/options.rs b/compiler/rustc_session/src/options.rs index 23e1856068940..1024555e86439 100644 --- a/compiler/rustc_session/src/options.rs +++ b/compiler/rustc_session/src/options.rs @@ -2316,6 +2316,10 @@ options! { dead_fn_used_set: Option = (None, parse_opt_pathbuf, [UNTRACKED], "path to a used-set file (mangled symbols a downstream binary reaches in this crate); \ enables cross-crate dead-fn elimination for library crates (experimental)"), + dead_fn_wait_used_set: Option = (None, parse_opt_number, [UNTRACKED], + "block after metadata emission for up to N seconds waiting for the used-set file to \ + appear, so a downstream binary's frontend can produce it while this library's codegen \ + is deferred in the same process — no frontend re-run (experimental)"), dead_fn_emit_used_set: Option = (None, parse_opt_pathbuf, [UNTRACKED], "walk this crate's MIR (no codegen needed) and write per-dependency used-set files \ into this directory: the extern fns it reaches, for cross-crate dead-fn elimination \ From b3de7c2150c2e65eae139307868e4bb31127a180 Mon Sep 17 00:00:00 2001 From: Yijun Yu Date: Thu, 9 Jul 2026 22:13:47 +0000 Subject: [PATCH 5/6] Update used_set module doc to match implementation The module comment still described the discarded nm/link-truth producer keyed on mangled symbol names; the code emits the used-set from the binary's MIR (pre-codegen) keyed on DefPathHash local_hash. Rewrite the doc to describe the implemented design so code and comment agree. --- compiler/rustc_monomorphize/src/used_set.rs | 36 +++++++++++---------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/compiler/rustc_monomorphize/src/used_set.rs b/compiler/rustc_monomorphize/src/used_set.rs index 90725ce4b9218..bff9937b11b5a 100644 --- a/compiler/rustc_monomorphize/src/used_set.rs +++ b/compiler/rustc_monomorphize/src/used_set.rs @@ -1,26 +1,28 @@ -//! Cross-crate used-set consumption for `-Z dead-fn-used-set=`. +//! Cross-crate used-set: the producer ([`emit_used_sets`]) and consumer ([`UsedSet`]) for +//! `-Zdead-fn-emit-used-set` / `-Zdead-fn-used-set`. //! //! In its binary-only form (see [`super::dead_fn_elim`]) the pass is a no-op: a binary's -//! monomorphization collector is already exact, so nothing it emits is unreachable. +//! monomorphization collector is already exact, so nothing it emits is unreachable. The +//! *cross-crate* form addresses the real slack: a library is compiled before its callers and +//! must conservatively emit its whole `pub` closure, most of which no final binary calls. //! -//! The *cross-crate* form addresses the real slack. A library is compiled before its -//! callers and must conservatively emit its whole `pub` closure, most of which no final -//! binary calls. Once a binary is linked, the set of a dependency's functions it actually -//! reaches is *link-truth*: the defined function symbols present in the final executable -//! (`nm`). Feeding that set back on the library's (re)compilation via `-Zdead-fn-used-set` -//! lets the library keep only those symbols (plus what soundness requires) and drop the -//! rest before `partition()`. +//! **Producer** ([`emit_used_sets`], run on the *binary* at `--emit=metadata`, before any +//! codegen): walk the binary's MIR and record every extern `fn` it references. This is the +//! set of a dependency's functions the binary actually reaches — computed without codegen, so +//! it can drive a *first-build* speedup. The binary's monomorphization collector cannot be +//! used here: it filters to local `DefId`s and never lists non-generic extern fns. //! -//! Identity is by **mangled symbol name**, matching link-truth exactly: a non-generic `pub` -//! function is codegen'd in the *dependency's* CGU and appears in the final binary as a -//! defined symbol, so `nm ` names precisely the reachable set. (The binary's own -//! monomorphization collector does *not* list these — it only monomorphizes generics — which -//! is why the used-set must come from the link, not the collector.) +//! **Consumer** ([`UsedSet`], run on each *library*): keep only the used-set functions (plus +//! what soundness requires) and drop the rest before `partition()`. //! -//! File format (one mangled symbol per line, `#`-comments and blanks ignored): +//! **Identity** is the `local_hash` half of [`DefPathHash`] — the def-path fingerprint within +//! a crate. It is stable across compilations, unlike the mangled symbol name, whose crate +//! disambiguator differs between the probe compile and the dependency's own compile. +//! +//! File format (one 16-hex `local_hash` per line, `#`-comments and blanks ignored): //! ```text -//! # used-set for `deplib`, from `nm -j ` -//! _RNvCseMk9t9oSu0C_6deplib8used_one +//! # used-set for `deplib` (MIR probe, DefPathHash local_hash) +//! 98912570aa6ef455 //! ``` use std::collections::BTreeMap; From 9dd33639bc05a6d04feb52f23edb4db02f399645 Mon Sep 17 00:00:00 2001 From: Yijun Yu Date: Thu, 9 Jul 2026 22:49:00 +0000 Subject: [PATCH 6/6] used-set probe: append (union across crates) instead of overwrite The used-set of a dependency is the union over every crate that calls it: in Main->A->B, B::used is referenced from A's body, not main's, so only A's probe names it. Each crate's probe therefore appends its extern references to the dependency's used-set file rather than overwriting; the consumer dedups on load. Verified on Main/A/B: B's used-set is complete (1 entry), 2000 dead fns pruned. --- compiler/rustc_monomorphize/src/used_set.rs | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/compiler/rustc_monomorphize/src/used_set.rs b/compiler/rustc_monomorphize/src/used_set.rs index bff9937b11b5a..5dab9c98472bb 100644 --- a/compiler/rustc_monomorphize/src/used_set.rs +++ b/compiler/rustc_monomorphize/src/used_set.rs @@ -147,15 +147,22 @@ pub(crate) fn emit_used_sets(tcx: TyCtxt<'_>, dir: &Path) { per_crate.entry(name).or_default().insert(local_hash); } + // The used-set of a dependency is the *union* over every crate that calls it: `Main`'s + // probe names `a::f`, but `b::used` is called from inside `a`'s body, so only `a`'s probe + // names it. Each crate therefore *appends* its references; duplicate lines are harmless + // (the consumer dedups into a set). Append is used rather than read-modify-write so the + // parallel crate compilations writing the same file do not race. + use std::io::Write; let mut wrote = 0; for (crate_name, hashes) in &per_crate { let path = dir.join(format!("{crate_name}.usedset")); - let mut body = - format!("# used-set for `{crate_name}` — {} entries (MIR probe, DefPathHash local_hash)\n", hashes.len()); + let mut body = String::new(); for h in hashes { body.push_str(&format!("{h:016x}\n")); } - if std::fs::write(&path, body).is_ok() { + if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&path) + && f.write_all(body.as_bytes()).is_ok() + { wrote += 1; } }