Skip to content

Commit 4ef4087

Browse files
authored
perf(turbopack): Merge nodes with same starting point (#76938)
### What? Implement one more trivial graph optimization ### Why? It reduces the number of modules marginally in real world apps. I posted the result at https://vercel.slack.com/archives/C04RPSD7EPM/p1732095860455549?thread_ts=1732091170.097799&cid=C04RPSD7EPM ### How? Closes PACK-3442
1 parent a6e6f70 commit 4ef4087

15 files changed

+561
-635
lines changed

turbopack/crates/turbopack-ecmascript/src/tree_shake/graph.rs

+10-29
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,6 @@ use super::{
3434
};
3535
use crate::{magic_identifier, tree_shake::optimizations::GraphOptimizer};
3636

37-
const FLAG_DISABLE_EXPORT_MERGING: &str = "TURBOPACK_DISABLE_EXPORT_MERGING";
3837
/// The id of an item
3938
#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
4039
pub(crate) enum ItemId {
@@ -130,9 +129,6 @@ pub(crate) struct ItemData {
130129
/// test case.
131130
pub explicit_deps: Vec<ItemId>,
132131

133-
/// Server actions breaks when we merge exports.
134-
pub disable_export_merging: bool,
135-
136132
pub is_module_evaluation: bool,
137133
}
138134

@@ -149,7 +145,6 @@ impl fmt::Debug for ItemData {
149145
.field("side_effects", &self.side_effects)
150146
.field("export", &self.export)
151147
.field("explicit_deps", &self.explicit_deps)
152-
.field("disable_export_merging", &self.disable_export_merging)
153148
.field("is_module_evaluation", &self.is_module_evaluation)
154149
.finish()
155150
}
@@ -170,7 +165,6 @@ impl Default for ItemData {
170165
export: Default::default(),
171166
binding_source: Default::default(),
172167
explicit_deps: Default::default(),
173-
disable_export_merging: Default::default(),
174168
is_module_evaluation: Default::default(),
175169
}
176170
}
@@ -748,13 +742,11 @@ impl DepGraph {
748742

749743
let optimizer = GraphOptimizer {
750744
graph_ix: &self.g.graph_ix,
751-
data,
752745
};
753-
loop {
754-
if !optimizer.merge_single_incoming_nodes(&mut condensed) {
755-
break;
756-
}
757-
}
746+
747+
while optimizer.merge_single_incoming_nodes(&mut condensed)
748+
|| optimizer.merge_nodes_with_same_starting_point(&mut condensed)
749+
{}
758750

759751
let mut new_graph = InternedGraph::default();
760752

@@ -837,23 +829,13 @@ impl DepGraph {
837829
match item {
838830
ModuleDecl::ExportDecl(item) => match &item.decl {
839831
Decl::Fn(FnDecl { ident, .. }) | Decl::Class(ClassDecl { ident, .. }) => {
840-
exports.push((
841-
ident.to_id(),
842-
ident.sym.clone(),
843-
comments.has_flag(ident.span().lo, FLAG_DISABLE_EXPORT_MERGING),
844-
));
832+
exports.push((ident.to_id(), ident.sym.clone()));
845833
}
846834
Decl::Var(v) => {
847835
for decl in &v.decls {
848-
let disable_export_merging = comments
849-
.has_flag(decl.name.span().lo, FLAG_DISABLE_EXPORT_MERGING)
850-
|| decl.init.as_deref().is_some_and(|e| {
851-
comments.has_flag(e.span().lo, FLAG_DISABLE_EXPORT_MERGING)
852-
});
853-
854836
let ids: Vec<Id> = find_pat_ids(&decl.name);
855837
for id in ids {
856-
exports.push((id.clone(), id.0, disable_export_merging));
838+
exports.push((id.clone(), id.0));
857839
}
858840
}
859841
}
@@ -924,7 +906,7 @@ impl DepGraph {
924906
local = local.into_private();
925907
}
926908

927-
exports.push((local.to_id(), exported.atom().clone(), false));
909+
exports.push((local.to_id(), exported.atom().clone()));
928910

929911
if let Some(src) = &item.src {
930912
let id = ItemId::Item {
@@ -1041,7 +1023,7 @@ impl DepGraph {
10411023
items.insert(id, data);
10421024
}
10431025

1044-
exports.push((default_var.to_id(), "default".into(), false));
1026+
exports.push((default_var.to_id(), "default".into()));
10451027
}
10461028
ModuleDecl::ExportDefaultExpr(export) => {
10471029
let default_var =
@@ -1100,7 +1082,7 @@ impl DepGraph {
11001082
{
11011083
// For export default __TURBOPACK__default__export__
11021084

1103-
exports.push((default_var.to_id(), "default".into(), false));
1085+
exports.push((default_var.to_id(), "default".into()));
11041086
}
11051087
}
11061088

@@ -1407,7 +1389,7 @@ impl DepGraph {
14071389
}
14081390
}
14091391

1410-
for (local, export_name, disable_export_merging) in exports {
1392+
for (local, export_name) in exports {
14111393
let id = ItemId::Group(ItemIdGroupKind::Export(local.clone(), export_name.clone()));
14121394
ids.push(id.clone());
14131395
items.insert(
@@ -1431,7 +1413,6 @@ impl DepGraph {
14311413
})),
14321414
read_vars: [local.clone()].into_iter().collect(),
14331415
export: Some(export_name),
1434-
disable_export_merging,
14351416
..Default::default()
14361417
},
14371418
);

turbopack/crates/turbopack-ecmascript/src/tree_shake/optimizations.rs

+132-6
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,13 @@
11
use std::ops::Index;
22

33
use petgraph::{visit::EdgeRef, Direction, Graph};
4-
use rustc_hash::FxHashMap;
4+
use rustc_hash::{FxHashMap, FxHashSet};
55
use turbo_tasks::FxIndexSet;
66

7-
use crate::tree_shake::graph::{Dependency, ItemData, ItemId, ItemIdGroupKind, ItemIdItemKind};
7+
use crate::tree_shake::graph::{Dependency, ItemId, ItemIdItemKind};
88

99
pub(super) struct GraphOptimizer<'a> {
1010
pub graph_ix: &'a FxIndexSet<ItemId>,
11-
pub data: &'a FxHashMap<ItemId, ItemData>,
1211
}
1312

1413
impl Index<u32> for GraphOptimizer<'_> {
@@ -33,14 +32,13 @@ impl GraphOptimizer<'_> {
3332
// imports for import bindings so the static code analysis pass can detect imports like
3433
// 'next/dynamic'.
3534

36-
(matches!(
35+
matches!(
3736
item_id,
3837
ItemId::Item {
3938
kind: ItemIdItemKind::ImportBinding(..),
4039
..
4140
}
42-
)) || (matches!(item_id, ItemId::Group(ItemIdGroupKind::Export(..)))
43-
&& self.data[item_id].disable_export_merging)
41+
)
4442
}
4543

4644
fn should_not_merge_iter<N>(&self, items: &[N]) -> bool
@@ -124,4 +122,132 @@ impl GraphOptimizer<'_> {
124122

125123
did_work
126124
}
125+
126+
/// This function merges nodes that can only be reached from a single starting point.
127+
/// Example:
128+
/// If we have a graph with edges: A->B, B->C, A->C, B->E, D->E
129+
/// Then B and C can only be reached from A, so they will be merged into A.
130+
/// The resulting graph would have edges like: (A,B,C)->E, D->E
131+
pub(super) fn merge_nodes_with_same_starting_point<N>(
132+
&self,
133+
g: &mut Graph<Vec<N>, Dependency>,
134+
) -> bool
135+
where
136+
N: Copy,
137+
Self: Index<N, Output = ItemId>,
138+
{
139+
let mut did_work = false;
140+
let mut reachability: FxHashMap<_, FxHashSet<_>> = FxHashMap::default();
141+
142+
// Step 1: Build a reverse reachability map (which starting nodes can reach each node)
143+
// We consider a "starting node" as one with no incoming edges
144+
let starting_nodes: Vec<_> = g
145+
.node_indices()
146+
.filter(|&node| g.edges_directed(node, Direction::Incoming).count() == 0)
147+
.collect();
148+
149+
// For each starting node, find all nodes reachable from it
150+
for &start in &starting_nodes {
151+
let mut visited = FxHashSet::default();
152+
let mut queue = vec![start];
153+
154+
while let Some(node) = queue.pop() {
155+
if !visited.insert(node) {
156+
continue;
157+
}
158+
159+
// For each outgoing edge, add the target to queue
160+
for edge in g.edges_directed(node, Direction::Outgoing) {
161+
let target = edge.target();
162+
queue.push(target);
163+
164+
// Add this starting node to the set of starting nodes that can reach target
165+
reachability.entry(target).or_default().insert(start);
166+
}
167+
}
168+
}
169+
170+
// Step 2: Find nodes that are reachable from exactly one starting node
171+
// and group them by that starting node
172+
let mut merge_groups: FxHashMap<_, Vec<_>> = FxHashMap::default();
173+
174+
for node in g.node_indices() {
175+
// Skip starting nodes
176+
if starting_nodes.contains(&node) {
177+
continue;
178+
}
179+
180+
// Skip nodes that should not be merged
181+
if self.should_not_merge_iter(g.node_weight(node).expect("Node should exist")) {
182+
continue;
183+
}
184+
185+
// If this node is reachable from exactly one starting node, add it to that group
186+
if let Some(reachable_from) = reachability.get(&node) {
187+
if reachable_from.len() == 1 {
188+
let start = *reachable_from.iter().next().unwrap();
189+
190+
// Don't merge if the starting node should not be merged
191+
if self.should_not_merge_iter(g.node_weight(start).expect("Node should exist"))
192+
{
193+
continue;
194+
}
195+
196+
merge_groups.entry(start).or_default().push(node);
197+
}
198+
}
199+
}
200+
201+
// Step 3: Merge nodes into their starting points
202+
for (start, nodes_to_merge) in merge_groups {
203+
if nodes_to_merge.is_empty() {
204+
continue;
205+
}
206+
207+
let mut nodes_to_remove = Vec::new();
208+
209+
for node in nodes_to_merge {
210+
// Move outgoing edges from node to start
211+
let outgoing_edges: Vec<_> = g
212+
.edges_directed(node, Direction::Outgoing)
213+
.map(|e| (e.target(), *e.weight()))
214+
.collect();
215+
216+
for (target, weight) in outgoing_edges {
217+
// If there's already an edge from start to target, only update if necessary
218+
let existing_edge = g.find_edge(start, target);
219+
match existing_edge {
220+
Some(e) => {
221+
let edge_weight = g.edge_weight_mut(e).unwrap();
222+
// Only upgrade from weak to strong dependency
223+
if matches!(edge_weight, Dependency::Weak)
224+
&& !matches!(weight, Dependency::Weak)
225+
{
226+
*edge_weight = weight;
227+
}
228+
}
229+
None => {
230+
// Add a new edge
231+
g.add_edge(start, target, weight);
232+
}
233+
}
234+
}
235+
236+
// Move items from this node to the starting node
237+
let items = g.node_weight(node).expect("Node should exist").clone();
238+
g.node_weight_mut(start).unwrap().extend(items);
239+
240+
nodes_to_remove.push(node);
241+
}
242+
243+
// Remove merged nodes (in reverse order to preserve indices)
244+
nodes_to_remove.sort();
245+
for node in nodes_to_remove.into_iter().rev() {
246+
g.remove_node(node);
247+
did_work = true;
248+
}
249+
}
250+
251+
did_work
252+
}
127253
}

0 commit comments

Comments
 (0)