Skip to main content

turbopack_core/module_graph/style_groups_graph/
mod.rs

1//! Graph-based CSS chunking algorithm.
2//!
3//! Selected by `experimental.cssChunking: "graph"` in Next.js. An alternative to the default
4//! ("loose") algorithm in [`super::style_groups`].
5//!
6//! # Pipeline
7//!
8//! ```text
9//! create_graph → make_acyclic → linearize → split_into_chunks → assemble batches
10//! ```
11//!
12//! 1. **`create_graph`** — for each chunk group, the ordered list of CSS modules is converted into
13//!    pairwise "later depends on earlier" edges in a directed weighted graph. Edge weights
14//!    accumulate when the same `(from, to)` pair occurs in multiple groups.
15//! 2. **`make_acyclic`** — co-occurrence almost always produces cycles. Each multi-node SCC has its
16//!    lowest-weight edge cut until the graph is a DAG. Heavy edges represent strong co-occurrence
17//!    and are preserved.
18//! 3. **`linearize`** — Kahn-style topological sort with a tie-break: when several dependents
19//!    become unblocked at once, the heaviest edge wins (and insertion order breaks ties among equal
20//!    weights). This places strongly co-occurring modules adjacent in the global order.
21//! 4. **`split_into_chunks`** — greedy bottom-up merger over the global order. At every active
22//!    split point we score the merge as `cost(merged) - cost(left) - cost(right)` and take the
23//!    most-negative score. We stop when no remaining merge would reduce cost.
24//!
25//! # Cost model
26//!
27//! Per chunk loaded by a chunk group:
28//!
29//! ```text
30//! cost_per_group(chunk, group)
31//!   = chunk_size
32//!   + (chunk_size / group_total_size) * module_factor_cost
33//!   + request_cost
34//! ```
35//!
36//! where `chunk_size` is the sum of module byte sizes in the chunk and `group_total_size` is the
37//! total CSS byte size of the chunk group. The total cost of a chunk is summed over the chunk
38//! groups that load it (a group "loads" a chunk if it shares ≥ 1 module with it).
39//!
40//! `request_cost` (in bytes — same unit as module sizes) charges for every CSS request a chunk
41//! group makes. Larger values bias toward fewer, larger shared chunks.
42//!
43//! `module_factor_cost` controls how much the algorithm cares about small chunk groups:
44//!
45//! * `0` distributes overshipped bytes evenly across chunk groups.
46//! * Higher values penalize overshipping in small chunk groups proportionally more, so small pages
47//!   ship fewer unrelated styles at the expense of more requests overall.
48//!
49//! # Constraints
50//!
51//! * `max_chunk_size` is enforced by treating any merge that would produce a multi-item chunk
52//!   exceeding the cap as `+infinity` cost (single-item chunks larger than the cap are left alone).
53//! * Global CSS (`StyleType::GlobalStyle`) must not leak into unrelated chunk groups: any merge
54//!   that would put a global item into a chunk loaded by a chunk group not currently loading that
55//!   item is treated as `+infinity` cost.
56
57use anyhow::Result;
58use indexmap::map::Entry;
59use rustc_hash::FxHashSet;
60use tracing::{Instrument, instrument};
61use turbo_tasks::{FxIndexMap, FxIndexSet, ResolvedVc, TryJoinIterExt, Vc};
62
63use crate::{
64    chunk::{
65        ChunkItemBatchWithAsyncModuleInfo, ChunkItemWithAsyncModuleInfo, ChunkType,
66        ChunkableModule, ChunkingContext, chunk_item_batch::attach_async_info_to_chunkable_module,
67    },
68    module::{StyleModule, StyleType},
69    module_graph::{
70        GraphTraversalAction, ModuleGraph,
71        module_batch::ModuleOrBatch,
72        module_batches::ModuleBatchesGraphEdge,
73        style_groups::{StyleGroups, StyleItemInfo, make_style_groups},
74    },
75};
76
77mod algorithm;
78mod subgraph_view;
79
80#[cfg(test)]
81mod tests;
82
83/// Per-CSS-module data the graph algorithm needs. Built once during the per-chunk-group walk.
84struct ModuleData {
85    style_type: StyleType,
86    /// Byte size of the module's chunk item.
87    size: u64,
88    chunk_item: ChunkItemWithAsyncModuleInfo,
89}
90
91/// A module that has been classified as a style module during the chunk-group walk. Carries both
92/// the chunkable view (for size + chunk-item resolution) and the style view (for `style_type`),
93/// so [`resolve_module_data`] doesn't need to repeat the sidecast.
94struct StyleModuleRef {
95    chunkable: ResolvedVc<Box<dyn ChunkableModule>>,
96    style: ResolvedVc<Box<dyn StyleModule>>,
97}
98
99/// Per-discovered-chunkable-module classification: `Some((id, style))` for CSS modules and
100/// `None` for non-CSS modules.
101type ClassifiedModule = Option<(usize, ResolvedVc<Box<dyn StyleModule>>)>;
102
103/// Build [`StyleGroups`] using the graph-analysis algorithm. See the module-level docs for
104/// details.
105#[instrument(skip(module_graph, chunking_context))]
106pub async fn compute_style_groups_graph(
107    module_graph: Vc<ModuleGraph>,
108    chunking_context: Vc<Box<dyn ChunkingContext>>,
109    request_cost: f32,
110    module_factor_cost: f32,
111    max_chunk_size: u64,
112) -> Result<Vc<StyleGroups>> {
113    // 1. Walk every chunk group post-order and collect, for each group, the ordered list of CSS
114    //    modules. Module ids are densely allocated as we encounter modules for the first time.
115    let (chunk_groups, modules_in_order) = collect_chunk_groups(module_graph, chunking_context)
116        .instrument(tracing::trace_span!("collect_chunk_groups"))
117        .await?;
118
119    if modules_in_order.is_empty() {
120        return Ok(make_style_groups(FxIndexMap::default()));
121    }
122
123    // 2. Resolve each module's `ChunkItemWithAsyncModuleInfo` and byte size in parallel.
124    let module_data = resolve_module_data(module_graph, chunking_context, &modules_in_order)
125        .instrument(tracing::trace_span!("resolve_module_data"))
126        .await?;
127
128    let module_sizes: Vec<u64> = module_data.iter().map(|m| m.size).collect();
129    let module_style_types: Vec<StyleType> = module_data.iter().map(|m| m.style_type).collect();
130
131    // 3. Run the synchronous chunking pipeline.
132    let mut graph = tracing::trace_span!("create_graph")
133        .in_scope(|| algorithm::create_graph(&chunk_groups, modules_in_order.len()));
134    tracing::trace_span!("make_acyclic").in_scope(|| algorithm::make_acyclic(&mut graph));
135    let global_order = tracing::trace_span!("linearize").in_scope(|| algorithm::linearize(&graph));
136    let chunks = tracing::trace_span!("split_into_chunks").in_scope(|| {
137        algorithm::split_into_chunks(
138            &global_order,
139            &chunk_groups,
140            &module_sizes,
141            &module_style_types,
142            request_cost,
143            module_factor_cost,
144            max_chunk_size,
145        )
146    });
147
148    // 4. Assemble the result. Each multi-item chunk becomes a `ChunkItemBatch`; singletons get a
149    //    `batch = None` entry so the production sort still places them at the right `order`.
150    assemble_style_groups(&chunks, &module_data)
151        .instrument(tracing::trace_span!("assemble"))
152        .await
153}
154
155async fn assemble_style_groups(
156    chunks: &[Vec<usize>],
157    module_data: &[ModuleData],
158) -> Result<Vc<StyleGroups>> {
159    let mut shared_chunk_items: FxIndexMap<ChunkItemWithAsyncModuleInfo, StyleItemInfo> =
160        FxIndexMap::default();
161    let mut order_counter: u32 = 0;
162    let mut push =
163        |map: &mut FxIndexMap<ChunkItemWithAsyncModuleInfo, StyleItemInfo>,
164         chunk_item: ChunkItemWithAsyncModuleInfo,
165         batch: Option<ResolvedVc<ChunkItemBatchWithAsyncModuleInfo>>| {
166            map.insert(
167                chunk_item,
168                StyleItemInfo {
169                    order: Some(order_counter),
170                    batch,
171                },
172            );
173            order_counter += 1;
174        };
175
176    for chunk in chunks {
177        if chunk.is_empty() {
178            continue;
179        }
180        if chunk.len() == 1 {
181            push(
182                &mut shared_chunk_items,
183                module_data[chunk[0]].chunk_item,
184                None,
185            );
186            continue;
187        }
188
189        let chunk_items: Vec<_> = chunk.iter().map(|&id| module_data[id].chunk_item).collect();
190        let batch = ChunkItemBatchWithAsyncModuleInfo::new(chunk_items.clone())
191            .to_resolved()
192            .await?;
193        for chunk_item in chunk_items {
194            push(&mut shared_chunk_items, chunk_item, Some(batch));
195        }
196    }
197
198    // `linearize` operates on a DAG and processes every node, so every module the algorithm saw
199    // must already have been emitted. Catch a future regression of that invariant in dev builds.
200    debug_assert!(
201        module_data
202            .iter()
203            .all(|data| shared_chunk_items.contains_key(&data.chunk_item)),
204        "linearize dropped a module: every module reached by the chunk-group walk must appear in \
205         the final chunk-item map",
206    );
207
208    Ok(make_style_groups(shared_chunk_items))
209}
210
211/// Walk every chunk group post-order, returning `(chunk_groups, modules_in_order)` where:
212/// * `chunk_groups[i]` is the list of CSS module ids loaded by chunk group `i` (after dedup of
213///   empty groups),
214/// * `modules_in_order` is the densely-numbered list of distinct CSS modules referenced by any
215///   chunk group, in insertion order.
216async fn collect_chunk_groups(
217    module_graph: Vc<ModuleGraph>,
218    chunking_context: Vc<Box<dyn ChunkingContext>>,
219) -> Result<(Vec<Vec<usize>>, Vec<StyleModuleRef>)> {
220    let chunk_group_info = module_graph.chunk_group_info().await?;
221    let batches_graph = module_graph
222        .module_batches(chunking_context.batching_config())
223        .await?;
224    // Per discovered chunkable module: `Some((id, sidecast_style))` for CSS modules and `None`
225    // for non-CSS modules (which still occupy an entry so we don't repeat the classification).
226    // Ids are densely packed in `0..modules_in_order.len()` — assigned via a separate counter
227    // because the underlying `IndexMap`'s insertion order also includes non-CSS entries.
228    let mut module_id_map: FxIndexMap<ResolvedVc<Box<dyn ChunkableModule>>, ClassifiedModule> =
229        FxIndexMap::default();
230    let mut next_css_id: usize = 0;
231    let mut chunk_groups: Vec<Vec<usize>> = Vec::new();
232
233    for (i, chunk_group) in chunk_group_info.chunk_groups.iter().enumerate() {
234        let ordered_entries = batches_graph.get_ordered_entries(&chunk_group_info, i);
235        let mut entries = Vec::with_capacity(chunk_group.entries_count());
236        for entry in ordered_entries {
237            entries.push(batches_graph.get_entry_index(entry).await?);
238        }
239        let mut visited = FxHashSet::default();
240        let mut items_in_postorder = FxIndexSet::default();
241        batches_graph.traverse_edges_from_entries_dfs(
242            entries.iter().copied(),
243            &mut (),
244            |parent_info, module, _| {
245                if let Some((_, ModuleBatchesGraphEdge { ty, .. })) = parent_info
246                    && !ty.is_parallel()
247                {
248                    return Ok(GraphTraversalAction::Exclude);
249                }
250                if visited.insert(module) {
251                    Ok(GraphTraversalAction::Continue)
252                } else {
253                    Ok(GraphTraversalAction::Exclude)
254                }
255            },
256            |parent_info, item, _| {
257                if let Some((_, ModuleBatchesGraphEdge { ty, .. })) = parent_info
258                    && !ty.is_parallel()
259                {
260                    return;
261                }
262                items_in_postorder.insert(*item);
263            },
264        )?;
265
266        // Collect CSS module ids for this group, classifying modules on first sight. `seen`
267        // dedups within a single group in O(1); the parallel `ids` Vec preserves insertion
268        // order.
269        let mut ids: Vec<usize> = Vec::new();
270        let mut seen: FxHashSet<usize> = FxHashSet::default();
271        let mut handle_module = async |module| -> Result<()> {
272            let id_slot = match module_id_map.entry(module) {
273                Entry::Occupied(e) => *e.get(),
274                Entry::Vacant(e) => {
275                    let assigned =
276                        ResolvedVc::try_sidecast::<Box<dyn StyleModule>>(module).map(|style| {
277                            let id = next_css_id;
278                            next_css_id += 1;
279                            (id, style)
280                        });
281                    e.insert(assigned);
282                    assigned
283                }
284            };
285            if let Some((id, _)) = id_slot
286                && seen.insert(id)
287            {
288                ids.push(id);
289            }
290            Ok(())
291        };
292
293        for item in items_in_postorder {
294            match item {
295                ModuleOrBatch::Batch(batch) => {
296                    for &module in &batch.await?.modules {
297                        handle_module(module).await?;
298                    }
299                }
300                ModuleOrBatch::Module(module) => {
301                    if let Some(chunkable_module) = ResolvedVc::try_downcast(module) {
302                        handle_module(chunkable_module).await?;
303                    }
304                }
305                ModuleOrBatch::None(_) => {}
306            }
307        }
308
309        if !ids.is_empty() {
310            chunk_groups.push(ids);
311        }
312    }
313
314    // Compact the id space: drop entries for non-CSS modules and keep CSS modules in insertion
315    // order. The sidecast `StyleModule` is carried through so [`resolve_module_data`] doesn't
316    // need to redo it.
317    let modules_in_order: Vec<StyleModuleRef> = module_id_map
318        .iter()
319        .filter_map(|(&chunkable, slot)| slot.map(|(_, style)| StyleModuleRef { chunkable, style }))
320        .collect();
321    Ok((chunk_groups, modules_in_order))
322}
323
324/// Resolve each module's chunk item and byte size in parallel. The returned vec is parallel to
325/// `modules`.
326async fn resolve_module_data(
327    module_graph: Vc<ModuleGraph>,
328    chunking_context: Vc<Box<dyn ChunkingContext>>,
329    modules: &[StyleModuleRef],
330) -> Result<Vec<ModuleData>> {
331    let async_module_info = module_graph.async_module_info();
332    modules
333        .iter()
334        .map(async |m| -> Result<ModuleData> {
335            let style_type = *m.style.style_type().await?;
336            let chunk_item = attach_async_info_to_chunkable_module(
337                m.chunkable,
338                async_module_info,
339                module_graph,
340                chunking_context,
341            )
342            .await?;
343            let size = *chunk_item
344                .chunk_type
345                .chunk_item_size(chunking_context, *chunk_item.chunk_item, None)
346                .await?;
347            Ok(ModuleData {
348                style_type,
349                size: size as u64,
350                chunk_item,
351            })
352        })
353        .try_join()
354        .await
355}