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