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_group_weight * (chunk_size + request_cost)
32//!
33//! chunk_group_weight = group_total_size ^ (-weight_distribution)
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//! `weight_distribution` controls how a chunk's cost is distributed across the chunk groups that
44//! load it, via the per-group weight `group_total_size ^ (-weight_distribution)`:
45//!
46//! * `0` weights every chunk group equally (the chunk's bytes/requests are spread evenly).
47//! * Higher values give smaller chunk groups a larger weight, so the algorithm cares proportionally
48//!   more about what it ships to them and overships less to small pages — at the expense of more
49//!   requests overall.
50//!
51//! # Constraints
52//!
53//! * `max_chunk_size` is enforced by treating any merge that would produce a multi-item chunk
54//!   exceeding the cap as `+infinity` cost (single-item chunks larger than the cap are left alone).
55//! * Global CSS (`StyleType::GlobalStyle`) must not leak into unrelated chunk groups: any merge
56//!   that would put a global item into a chunk loaded by a chunk group not currently loading that
57//!   item is treated as `+infinity` cost.
58
59use std::{
60    sync::{
61        LazyLock,
62        atomic::{AtomicU64, Ordering},
63    },
64    time::{SystemTime, UNIX_EPOCH},
65};
66
67use anyhow::Result;
68use indexmap::map::Entry;
69use petgraph::graph::NodeIndex;
70use rustc_hash::FxHashSet;
71use tracing::{Instrument, instrument};
72use turbo_tasks::{FxIndexMap, FxIndexSet, ResolvedVc, TryJoinIterExt, Vc};
73
74use crate::{
75    chunk::{
76        ChunkItemBatchWithAsyncModuleInfo, ChunkItemWithAsyncModuleInfo, ChunkType,
77        ChunkableModule, ChunkingContext, chunk_item_batch::attach_async_info_to_chunkable_module,
78    },
79    module::{Module, StyleModule, StyleType},
80    module_graph::{
81        GraphTraversalAction, ModuleGraph,
82        module_batch::ModuleOrBatch,
83        module_batches::ModuleBatchesGraphEdge,
84        style_groups::{StyleGroups, StyleItemInfo, make_style_groups},
85    },
86};
87
88mod algorithm;
89mod subgraph_view;
90
91#[cfg(test)]
92mod tests;
93
94/// Per-CSS-module data the graph algorithm needs. Built once during the per-chunk-group walk.
95struct ModuleData {
96    style_type: StyleType,
97    /// Byte size of the module's chunk item.
98    size: u64,
99    chunk_item: ChunkItemWithAsyncModuleInfo,
100}
101
102/// A module that has been classified as a style module during the chunk-group walk. Carries both
103/// the chunkable view (for size + chunk-item resolution) and the style view (for `style_type`),
104/// so [`resolve_module_data`] doesn't need to repeat the sidecast.
105struct StyleModuleRef {
106    chunkable: ResolvedVc<Box<dyn ChunkableModule>>,
107    style: ResolvedVc<Box<dyn StyleModule>>,
108}
109
110/// Per-discovered-chunkable-module classification: `Some((id, style))` for CSS modules and
111/// `None` for non-CSS modules.
112type ClassifiedModule = Option<(usize, ResolvedVc<Box<dyn StyleModule>>)>;
113
114/// Build [`StyleGroups`] using the graph-analysis algorithm. See the module-level docs for
115/// details.
116#[instrument(skip(module_graph, chunking_context))]
117pub async fn compute_style_groups_graph(
118    module_graph: Vc<ModuleGraph>,
119    chunking_context: Vc<Box<dyn ChunkingContext>>,
120    request_cost: f32,
121    weight_distribution: f32,
122    max_chunk_size: u64,
123) -> Result<Vc<StyleGroups>> {
124    // 1. Walk every chunk group post-order and collect, for each group, the ordered list of CSS
125    //    modules. Module ids are densely allocated as we encounter modules for the first time.
126    let (chunk_groups, modules_in_order) = collect_chunk_groups(module_graph, chunking_context)
127        .instrument(tracing::trace_span!("collect_chunk_groups"))
128        .await?;
129
130    if modules_in_order.is_empty() {
131        return Ok(make_style_groups(FxIndexMap::default()));
132    }
133
134    // 2. Resolve each module's `ChunkItemWithAsyncModuleInfo` and byte size in parallel.
135    let module_data = resolve_module_data(module_graph, chunking_context, &modules_in_order)
136        .instrument(tracing::trace_span!("resolve_module_data"))
137        .await?;
138
139    let module_sizes: Vec<u64> = module_data.iter().map(|m| m.size).collect();
140    let module_style_types: Vec<StyleType> = module_data.iter().map(|m| m.style_type).collect();
141
142    // 3. Run the synchronous chunking pipeline.
143    let (mut graph, module_to_groups) = tracing::trace_span!("create_graph")
144        .in_scope(|| algorithm::create_graph(&chunk_groups, modules_in_order.len()));
145    tracing::trace_span!("make_acyclic").in_scope(|| algorithm::make_acyclic(&mut graph));
146    let global_order = tracing::trace_span!("linearize")
147        .in_scope(|| algorithm::linearize(&graph, &module_to_groups));
148    let chunks = tracing::trace_span!("split_into_chunks").in_scope(|| {
149        algorithm::split_into_chunks(
150            &global_order,
151            &chunk_groups,
152            &module_sizes,
153            &module_style_types,
154            request_cost,
155            weight_distribution,
156            max_chunk_size,
157        )
158    });
159
160    // Optional debug dump controlled by `TURBOPACK_DEBUG_CSS_CHUNKING`. Failures here are
161    // logged and otherwise swallowed so a debug toggle never breaks the build.
162    if *DEBUG_DUMP_ENABLED
163        && let Err(err) = write_debug_dump(
164            &chunk_groups,
165            &modules_in_order,
166            &module_data,
167            &global_order,
168            &chunks,
169        )
170        .instrument(tracing::trace_span!("debug_dump"))
171        .await
172    {
173        eprintln!("TURBOPACK_DEBUG_CSS_CHUNKING: failed to write debug dump: {err:?}");
174    }
175
176    // 4. Assemble the result. Each multi-item chunk becomes a `ChunkItemBatch`; singletons get a
177    //    `batch = None` entry so the production sort still places them at the right `order`.
178    assemble_style_groups(&chunks, &module_data)
179        .instrument(tracing::trace_span!("assemble"))
180        .await
181}
182
183static DEBUG_DUMP_ENABLED: LazyLock<bool> =
184    LazyLock::new(|| match std::env::var("TURBOPACK_DEBUG_CSS_CHUNKING") {
185        Ok(v) => !v.is_empty() && v != "0" && !v.eq_ignore_ascii_case("false"),
186        Err(_) => false,
187    });
188
189/// Serialize a split cost metric for the debug dump. `serde_json` renders non-finite floats as
190/// `null`, which would be indistinguishable from the genuine `None` of the last chunk, so the
191/// non-finite cases are emitted as strings instead.
192fn cost_to_json(cost: Option<f32>) -> serde_json::Value {
193    match cost {
194        None => serde_json::Value::Null,
195        Some(c) if c.is_finite() => serde_json::json!(c),
196        Some(c) if c == f32::NEG_INFINITY => serde_json::json!("-Infinity"),
197        Some(c) if c == f32::INFINITY => serde_json::json!("Infinity"),
198        Some(_) => serde_json::json!("NaN"),
199    }
200}
201
202/// Write a JSON snapshot of the inputs and outputs of the graph-based CSS chunker to the
203/// current working directory. Each invocation produces a uniquely named file so concurrent or
204/// repeated computations don't overwrite each other.
205async fn write_debug_dump(
206    chunk_groups: &[Vec<usize>],
207    modules: &[StyleModuleRef],
208    module_data: &[ModuleData],
209    global_order: &[NodeIndex],
210    chunks: &[(Vec<usize>, Option<f32>)],
211) -> Result<()> {
212    // Resolve `ident_string()` for every module up front. Done in parallel to keep this off the
213    // critical path even on graphs with thousands of CSS modules.
214    let ident_strings: Vec<String> = modules
215        .iter()
216        .map(async |m| -> Result<String> {
217            Ok(m.chunkable.ident_string().await?.as_str().to_owned())
218        })
219        .try_join()
220        .await?;
221
222    let ident = |id: usize| ident_strings[id].as_str();
223
224    let chunk_groups_json: Vec<Vec<&str>> = chunk_groups
225        .iter()
226        .map(|g| g.iter().map(|&id| ident(id)).collect())
227        .collect();
228
229    // Each chunk carries the cost-delta of merging it with the following chunk (`None`/`null` for
230    // the last chunk). A finite value is the surviving split metric; `"Infinity"` marks a boundary
231    // a hard constraint forbade merging across.
232    let mut chunks_json: Vec<serde_json::Value> = chunks
233        .iter()
234        .flat_map(|(chunk, merge_cost_to_next)| {
235            [
236                serde_json::json!(chunk.iter().map(|&id| ident(id)).collect::<Vec<_>>()),
237                serde_json::json!(cost_to_json(*merge_cost_to_next)),
238            ]
239        })
240        .collect();
241
242    // last chunk has no merge cost
243    chunks_json.pop();
244
245    let global_order_flat_json: Vec<&str> = global_order.iter().map(|n| ident(n.index())).collect();
246
247    let modules_json: Vec<serde_json::Value> = modules
248        .iter()
249        .enumerate()
250        .map(|(i, _)| {
251            serde_json::json!({
252                "ident": ident(i),
253                "size": module_data[i].size,
254                "style_type": match module_data[i].style_type {
255                    StyleType::GlobalStyle => "GlobalStyle",
256                    StyleType::IsolatedStyle => "IsolatedStyle",
257                },
258            })
259        })
260        .collect();
261
262    let dump = serde_json::json!({
263        "chunk_groups": chunk_groups_json,
264        "global_order": global_order_flat_json,
265        "chunks": chunks_json,
266        "modules": modules_json,
267    });
268
269    let now_ms = SystemTime::now()
270        .duration_since(UNIX_EPOCH)
271        .map(|d| d.as_millis())
272        .unwrap_or(0);
273    static COUNTER: AtomicU64 = AtomicU64::new(0);
274    let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
275    let path =
276        std::env::current_dir()?.join(format!("turbopack-css-chunking-debug-{now_ms}-{seq}.json"));
277
278    let bytes = serde_json::to_vec_pretty(&dump)?;
279    std::fs::write(&path, &bytes)?;
280    eprintln!(
281        "TURBOPACK_DEBUG_CSS_CHUNKING: wrote {} bytes to {}",
282        bytes.len(),
283        path.display()
284    );
285    Ok(())
286}
287
288async fn assemble_style_groups(
289    chunks: &[(Vec<usize>, Option<f32>)],
290    module_data: &[ModuleData],
291) -> Result<Vc<StyleGroups>> {
292    let mut shared_chunk_items: FxIndexMap<ChunkItemWithAsyncModuleInfo, StyleItemInfo> =
293        FxIndexMap::default();
294    let mut order_counter: u32 = 0;
295    let mut push =
296        |map: &mut FxIndexMap<ChunkItemWithAsyncModuleInfo, StyleItemInfo>,
297         chunk_item: ChunkItemWithAsyncModuleInfo,
298         batch: Option<ResolvedVc<ChunkItemBatchWithAsyncModuleInfo>>| {
299            map.insert(
300                chunk_item,
301                StyleItemInfo {
302                    order: Some(order_counter),
303                    batch,
304                },
305            );
306            order_counter += 1;
307        };
308
309    for (chunk, _cost) in chunks {
310        if chunk.is_empty() {
311            continue;
312        }
313        if chunk.len() == 1 {
314            push(
315                &mut shared_chunk_items,
316                module_data[chunk[0]].chunk_item,
317                None,
318            );
319            continue;
320        }
321
322        let chunk_items: Vec<_> = chunk.iter().map(|&id| module_data[id].chunk_item).collect();
323        let batch = ChunkItemBatchWithAsyncModuleInfo::new(chunk_items.clone())
324            .to_resolved()
325            .await?;
326        for chunk_item in chunk_items {
327            push(&mut shared_chunk_items, chunk_item, Some(batch));
328        }
329    }
330
331    // `linearize` operates on a DAG and processes every node, so every module the algorithm saw
332    // must already have been emitted. Catch a future regression of that invariant in dev builds.
333    debug_assert!(
334        module_data
335            .iter()
336            .all(|data| shared_chunk_items.contains_key(&data.chunk_item)),
337        "linearize dropped a module: every module reached by the chunk-group walk must appear in \
338         the final chunk-item map",
339    );
340
341    Ok(make_style_groups(shared_chunk_items))
342}
343
344/// Walk every chunk group post-order, returning `(chunk_groups, modules_in_order)` where:
345/// * `chunk_groups[i]` is the list of CSS module ids loaded by chunk group `i` (after dedup of
346///   empty groups),
347/// * `modules_in_order` is the densely-numbered list of distinct CSS modules referenced by any
348///   chunk group, in insertion order.
349async fn collect_chunk_groups(
350    module_graph: Vc<ModuleGraph>,
351    chunking_context: Vc<Box<dyn ChunkingContext>>,
352) -> Result<(Vec<Vec<usize>>, Vec<StyleModuleRef>)> {
353    let chunk_group_info = module_graph.chunk_group_info().await?;
354    let batches_graph = module_graph
355        .module_batches(chunking_context.batching_config())
356        .await?;
357    // Per discovered chunkable module: `Some((id, sidecast_style))` for CSS modules and `None`
358    // for non-CSS modules (which still occupy an entry so we don't repeat the classification).
359    // Ids are densely packed in `0..modules_in_order.len()` — assigned via a separate counter
360    // because the underlying `IndexMap`'s insertion order also includes non-CSS entries.
361    let mut module_id_map: FxIndexMap<ResolvedVc<Box<dyn ChunkableModule>>, ClassifiedModule> =
362        FxIndexMap::default();
363    let mut next_css_id: usize = 0;
364    let mut chunk_groups: Vec<Vec<usize>> = Vec::new();
365
366    for (i, chunk_group) in chunk_group_info.chunk_groups.iter().enumerate() {
367        let ordered_entries = batches_graph.get_ordered_entries(&chunk_group_info, i);
368        let mut entries = Vec::with_capacity(chunk_group.entries_count());
369        for entry in ordered_entries {
370            entries.push(batches_graph.get_entry_index(entry).await?);
371        }
372        let mut visited = FxHashSet::default();
373        let mut items_in_postorder = FxIndexSet::default();
374        batches_graph.traverse_edges_from_entries_dfs(
375            entries.iter().copied(),
376            &mut (),
377            |parent_info, module, _| {
378                if let Some((_, ModuleBatchesGraphEdge { ty, .. })) = parent_info
379                    && !ty.is_parallel()
380                {
381                    return Ok(GraphTraversalAction::Exclude);
382                }
383                if visited.insert(module) {
384                    Ok(GraphTraversalAction::Continue)
385                } else {
386                    Ok(GraphTraversalAction::Exclude)
387                }
388            },
389            |parent_info, item, _| {
390                if let Some((_, ModuleBatchesGraphEdge { ty, .. })) = parent_info
391                    && !ty.is_parallel()
392                {
393                    return;
394                }
395                items_in_postorder.insert(*item);
396            },
397        )?;
398
399        // Collect CSS module ids for this group, classifying modules on first sight. `seen`
400        // dedups within a single group in O(1); the parallel `ids` Vec preserves insertion
401        // order.
402        let mut ids: Vec<usize> = Vec::new();
403        let mut seen: FxHashSet<usize> = FxHashSet::default();
404        let mut handle_module = async |module| -> Result<()> {
405            let id_slot = match module_id_map.entry(module) {
406                Entry::Occupied(e) => *e.get(),
407                Entry::Vacant(e) => {
408                    let assigned =
409                        ResolvedVc::try_sidecast::<Box<dyn StyleModule>>(module).map(|style| {
410                            let id = next_css_id;
411                            next_css_id += 1;
412                            (id, style)
413                        });
414                    e.insert(assigned);
415                    assigned
416                }
417            };
418            if let Some((id, _)) = id_slot
419                && seen.insert(id)
420            {
421                ids.push(id);
422            }
423            Ok(())
424        };
425
426        for item in items_in_postorder {
427            match item {
428                ModuleOrBatch::Batch(batch) => {
429                    for &module in &batch.await?.modules {
430                        handle_module(module).await?;
431                    }
432                }
433                ModuleOrBatch::Module(module) => {
434                    if let Some(chunkable_module) = ResolvedVc::try_downcast(module) {
435                        handle_module(chunkable_module).await?;
436                    }
437                }
438                ModuleOrBatch::None(_) => {}
439            }
440        }
441
442        if !ids.is_empty() {
443            chunk_groups.push(ids);
444        }
445    }
446
447    // Compact the id space: drop entries for non-CSS modules and keep CSS modules in insertion
448    // order. The sidecast `StyleModule` is carried through so [`resolve_module_data`] doesn't
449    // need to redo it.
450    let modules_in_order: Vec<StyleModuleRef> = module_id_map
451        .iter()
452        .filter_map(|(&chunkable, slot)| slot.map(|(_, style)| StyleModuleRef { chunkable, style }))
453        .collect();
454    Ok((chunk_groups, modules_in_order))
455}
456
457/// Resolve each module's chunk item and byte size in parallel. The returned vec is parallel to
458/// `modules`.
459async fn resolve_module_data(
460    module_graph: Vc<ModuleGraph>,
461    chunking_context: Vc<Box<dyn ChunkingContext>>,
462    modules: &[StyleModuleRef],
463) -> Result<Vec<ModuleData>> {
464    let async_module_info = module_graph.async_module_info();
465    modules
466        .iter()
467        .map(async |m| -> Result<ModuleData> {
468            let style_type = *m.style.style_type().await?;
469            let chunk_item = attach_async_info_to_chunkable_module(
470                m.chunkable,
471                async_module_info,
472                module_graph,
473                chunking_context,
474            )
475            .await?;
476            let size = *chunk_item
477                .chunk_type
478                .chunk_item_size(chunking_context, *chunk_item.chunk_item, None)
479                .await?;
480            Ok(ModuleData {
481                style_type,
482                size: size as u64,
483                chunk_item,
484            })
485        })
486        .try_join()
487        .await
488}