Skip to main content

fallow_engine/
entry_weight.rs

1//! Startup import weight per runtime entry point.
2//!
3//! For each runtime entry, the report counts the project modules and source
4//! bytes that load before the entry runs, and compares them with the modules
5//! that load only on demand or only on another thread. It also names the
6//! single imports that keep the most bytes on the startup path. The numbers
7//! come from on-disk file sizes and a traversal in `FileId` order, so repeated
8//! runs on the same tree give identical output.
9
10use std::collections::{BTreeMap, BTreeSet};
11use std::path::Path;
12
13use fallow_graph::graph::{DominatingImport, ModuleGraph, is_declaration_file_path};
14use fallow_output::{
15    DominatingImportOutput, EagerPackageOutput, EntryWeightListing, EntryWeightOutput,
16    EntryWeightUnit,
17};
18use fallow_types::discover::{DiscoveredFile, EntryPoint, FileId};
19use rustc_hash::FxHashMap;
20
21use crate::session::AnalysisSession;
22
23/// Dominating imports reported per entry.
24pub const DOMINATING_IMPORT_LIMIT: usize = 10;
25
26/// Stylesheet extensions whose bytes also count as `eager_css_bytes`.
27const STYLESHEET_EXTENSIONS: &[&str] = &["css", "scss", "sass", "less"];
28
29/// Label for an entry whose declaring source is not known.
30const UNKNOWN_ENTRY_SOURCE: &str = "entry point";
31
32/// Compute the startup import weight of every runtime entry point.
33///
34/// `entry_points` gives the declaring source of each entry, as the listing
35/// reports it. Entries are sorted by `eager_bytes` (heaviest first), then by
36/// path.
37///
38/// # Errors
39///
40/// Returns an error if parsing or graph construction fails.
41pub fn compute_entry_weight(
42    session: &AnalysisSession,
43    entry_points: &[EntryPoint],
44) -> crate::EngineResult<EntryWeightListing> {
45    let artifacts = session.analyze_dead_code_with_shared_artifacts(false, true)?;
46    let graph = artifacts
47        .graph
48        .as_ref()
49        .ok_or_else(|| crate::EngineError::new("entry weight requires a retained module graph"))?;
50    Ok(entry_weight_listing(
51        graph.as_graph(),
52        session.files(),
53        entry_points,
54        session.root(),
55    ))
56}
57
58/// Build the listing from a module graph and the discovered files.
59#[must_use]
60pub fn entry_weight_listing(
61    graph: &ModuleGraph,
62    files: &[DiscoveredFile],
63    entry_points: &[EntryPoint],
64    root: &Path,
65) -> EntryWeightListing {
66    let sizes = FileSizes::new(files);
67    let sources: FxHashMap<&Path, String> = entry_points
68        .iter()
69        .map(|entry| (entry.path.as_path(), entry.source.to_string()))
70        .collect();
71    let mut entry_ids: Vec<FileId> = graph.runtime_entry_points.iter().copied().collect();
72    entry_ids.sort_unstable_by_key(|id| id.0);
73
74    let mut line_offsets = LineOffsetCache::default();
75    let mut entries: Vec<EntryWeightOutput> = entry_ids
76        .into_iter()
77        .filter_map(|entry| {
78            let module = graph.modules.get(entry.0 as usize)?;
79            // A declaration file is erased at build time, so nothing loads it.
80            if is_declaration_file_path(&module.path) {
81                return None;
82            }
83            let source = sources
84                .get(module.path.as_path())
85                .cloned()
86                .unwrap_or_else(|| UNKNOWN_ENTRY_SOURCE.to_string());
87            Some(entry_weight(
88                graph,
89                EntryRow {
90                    entry,
91                    source,
92                    root,
93                    sizes: &sizes,
94                },
95                &mut line_offsets,
96            ))
97        })
98        .collect();
99    entries.sort_by(|a, b| {
100        b.eager_bytes
101            .cmp(&a.eager_bytes)
102            .then_with(|| a.path.cmp(&b.path))
103    });
104
105    EntryWeightListing {
106        unit: EntryWeightUnit::SourceBytes,
107        entry_count: entries.len(),
108        entries,
109        regression: None,
110    }
111}
112
113struct EntryRow<'a> {
114    entry: FileId,
115    source: String,
116    root: &'a Path,
117    sizes: &'a FileSizes,
118}
119
120fn entry_weight(
121    graph: &ModuleGraph,
122    row: EntryRow<'_>,
123    line_offsets: &mut LineOffsetCache,
124) -> EntryWeightOutput {
125    let EntryRow {
126        entry,
127        source,
128        root,
129        sizes,
130    } = row;
131    let closure = graph.entry_load_closure(entry);
132    let eager_css_bytes = closure
133        .eager
134        .iter()
135        .filter(|&&id| {
136            graph
137                .modules
138                .get(id.0 as usize)
139                .is_some_and(|m| is_stylesheet(&m.path))
140        })
141        .map(|&id| sizes.get(id))
142        .sum();
143    let eager_packages = eager_packages(graph, &closure.eager);
144    let dominating_imports = graph
145        .eager_dominating_imports(entry, &closure.eager, |id| sizes.get(id))
146        .into_iter()
147        .take(DOMINATING_IMPORT_LIMIT)
148        .map(|import| dominating_import_output(graph, root, &import, line_offsets))
149        .collect();
150
151    EntryWeightOutput {
152        path: relative_path(graph, entry, root),
153        source,
154        eager_modules: closure.eager.len(),
155        eager_bytes: sizes.sum(&closure.eager),
156        eager_css_bytes,
157        deferred_modules: closure.deferred.len(),
158        deferred_bytes: sizes.sum(&closure.deferred),
159        out_of_thread_modules: closure.out_of_thread.len(),
160        out_of_thread_bytes: sizes.sum(&closure.out_of_thread),
161        eager_package_count: eager_packages.len(),
162        eager_packages,
163        dominating_imports,
164    }
165}
166
167fn eager_packages(graph: &ModuleGraph, eager: &[FileId]) -> Vec<EagerPackageOutput> {
168    let mut packages: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
169    for id in eager {
170        let Some(imports) = graph.eager_package_imports.get(id) else {
171            continue;
172        };
173        for import in imports
174            .iter()
175            .filter(|import| !crate::core_backend::is_builtin_module(&import.package))
176        {
177            packages
178                .entry(import.package.as_str())
179                .or_default()
180                .insert(import.specifier.as_str());
181        }
182    }
183    packages
184        .into_iter()
185        .map(|(name, specifiers)| EagerPackageOutput {
186            name: name.to_string(),
187            specifiers: specifiers.into_iter().map(str::to_string).collect(),
188        })
189        .collect()
190}
191
192fn dominating_import_output(
193    graph: &ModuleGraph,
194    root: &Path,
195    import: &DominatingImport,
196    line_offsets: &mut LineOffsetCache,
197) -> DominatingImportOutput {
198    let line = import
199        .import_span_start
200        .and_then(|start| line_offsets.line(graph, import.importer, start));
201    DominatingImportOutput {
202        importer: relative_path(graph, import.importer, root),
203        line,
204        target: relative_path(graph, import.target, root),
205        exclusive_bytes: import.exclusive_weight,
206        exclusive_modules: import.exclusive_modules,
207    }
208}
209
210fn relative_path(graph: &ModuleGraph, id: FileId, root: &Path) -> String {
211    graph
212        .modules
213        .get(id.0 as usize)
214        .map(|module| crate::trace::trace_impl::relativize(&module.path, root))
215        .unwrap_or_default()
216}
217
218fn is_stylesheet(path: &Path) -> bool {
219    path.extension()
220        .and_then(|ext| ext.to_str())
221        .is_some_and(|ext| {
222            STYLESHEET_EXTENSIONS
223                .iter()
224                .any(|known| ext.eq_ignore_ascii_case(known))
225        })
226}
227
228/// On-disk file sizes indexed by `FileId`.
229struct FileSizes(Vec<u64>);
230
231impl FileSizes {
232    fn new(files: &[DiscoveredFile]) -> Self {
233        let len = files.iter().map(|f| f.id.0 as usize + 1).max().unwrap_or(0);
234        let mut sizes = vec![0; len];
235        for file in files {
236            sizes[file.id.0 as usize] = file.size_bytes;
237        }
238        Self(sizes)
239    }
240
241    fn get(&self, id: FileId) -> u64 {
242        self.0.get(id.0 as usize).copied().unwrap_or(0)
243    }
244
245    fn sum(&self, ids: &[FileId]) -> u64 {
246        ids.iter().map(|&id| self.get(id)).sum()
247    }
248}
249
250/// Line offsets of importer files, read on first use.
251#[derive(Default)]
252struct LineOffsetCache(FxHashMap<FileId, Option<Vec<u32>>>);
253
254impl LineOffsetCache {
255    fn line(&mut self, graph: &ModuleGraph, file: FileId, byte_offset: u32) -> Option<u32> {
256        let offsets = self.0.entry(file).or_insert_with(|| {
257            let module = graph.modules.get(file.0 as usize)?;
258            std::fs::read_to_string(&module.path)
259                .ok()
260                .map(|source| fallow_types::extract::compute_line_offsets(&source))
261        });
262        offsets
263            .as_ref()
264            .map(|offsets| fallow_types::extract::byte_offset_to_line_col(offsets, byte_offset).0)
265    }
266}