Turbopack: add TURBOPACK_DEBUG_CSS_CHUNKING env var (#95080)

### What?

Adds a `TURBOPACK_DEBUG_CSS_CHUNKING` environment variable. When set to
a truthy value, the graph-based CSS chunker (`experimental.cssChunking:
"graph"`) writes a JSON snapshot of its inputs and outputs to the
current working directory on each invocation.

### Why?

We are investigating problems with the new graph-based CSS chunking
algorithm. The pipeline (`create_graph` → `make_acyclic` → `linearize` →
`split_into_chunks`) is internal to `turbopack-core` and doesn't surface
enough information through normal build output to debug bad chunking
decisions post-hoc. A side-channel dump lets us reproduce and reason
about a problematic build without instrumenting Rust on a user's
machine.

### How?

In
`turbopack/crates/turbopack-core/src/module_graph/style_groups_graph/mod.rs`,
between `split_into_chunks` and the result assembly:

- Whether the dump is enabled is cached in a `static DEBUG_DUMP_ENABLED:
LazyLock<bool>` so the env var is read exactly once per process. Truthy
= anything other than unset, empty, `0`, or `false` (case-insensitive).
- When enabled, every call to `compute_style_groups_graph` resolves
`ident_string()` for every CSS module in parallel via `try_join` and
writes a pretty-printed JSON file
`turbopack-css-chunking-debug-<unix_ms>-<seq>.json` in the current
working directory. The timestamp + atomic counter suffix ensures
concurrent or repeated calls don't clobber each other.
- Failures while writing the dump are logged to stderr and otherwise
swallowed — a debug toggle must never fail a build.

The JSON document contains:

- `chunk_groups`: `string[][]` — module idents per chunk group, in the
order the algorithm sees them.
- `global_order`: `string[]` — the flat global order produced by
`linearize`.
- `global_order_chunks`: `string[][]` — the same modules grouped by the
merged segments produced by `split_into_chunks` (e.g. `[["a","b"],
["c"], ["d","e","f"]]`).
- `modules`: `[{ ident, size, style_type }]` — per-module metadata where
`size` is the chunk item size in bytes (the same value used by the cost
model) and `style_type` is `"GlobalStyle"` or `"IsolatedStyle"`.

Verification: `cargo clippy -p turbopack-core --no-deps` is clean and
the existing `style_groups_graph` test suite passes (53 tests).

No user-facing config change, no docs change — the env var is
intentionally undocumented and only meant for triage.

Closes NEXT-
Fixes #

---------

Co-authored-by: v-work-app[bot] <262237222+v-work-app[bot]@users.noreply.github.com>
Co-authored-by: Claude <[email protected]>
Co-authored-by: Tobias Koppers <[email protected]>
This commit is contained in:
Tobias Koppers
2026-06-24 03:10:16 +02:00
committed by GitHub
co-authored by v-work-app[bot] <262237222+v-work-app[bot]@users.noreply.github.com> Claude Tobias Koppers
parent b0d33e9afb
commit 16db9d0544
3 changed files with 151 additions and 7 deletions
@@ -538,6 +538,13 @@ impl PartialOrd for OrdF32 {
/// * `module_factor_cost` — see module-level docs.
/// * `max_chunk_size` — bytes; merges that produce a multi-item chunk above this are forbidden
/// (`+infinity`). `0` disables the cap.
///
/// Returns one `(chunk, merge_cost_to_next)` tuple per chunk, in global order. `merge_cost_to_next`
/// is the surviving split's metric — the cost-delta of merging this chunk with the following one
/// (`cost(merged) - cost(this) - cost(next)`, always `>= 0` since every negative metric was merged
/// away). It is `None` for the last chunk, which has no following neighbour. These metrics are
/// surfaced by the `TURBOPACK_DEBUG_CSS_CHUNKING` dump so a boundary that was close to merging can
/// be told apart from one held back by a hard constraint (`+infinity`).
pub(super) fn split_into_chunks(
global_order: &[NodeIndex],
chunk_groups: &[Vec<usize>],
@@ -546,7 +553,7 @@ pub(super) fn split_into_chunks(
request_cost: f32,
module_factor_cost: f32,
max_chunk_size: u64,
) -> Vec<Vec<usize>> {
) -> Vec<(Vec<usize>, Option<f32>)> {
if global_order.is_empty() {
return Vec::new();
}
@@ -641,12 +648,14 @@ pub(super) fn split_into_chunks(
}
// Materialize chunks by walking `order` and starting a new chunk on each true split point.
let mut result: Vec<Vec<usize>> = vec![vec![order[0]]];
// When a split closes the current chunk, record its metric as that chunk's cost-to-next.
let mut result: Vec<(Vec<usize>, Option<f32>)> = vec![(vec![order[0]], None)];
for i in 1..n {
if split_points[i - 1] {
result.push(vec![order[i]]);
result.last_mut().unwrap().1 = metrics[i - 1];
result.push((vec![order[i]], None));
} else {
result.last_mut().unwrap().push(order[i]);
result.last_mut().unwrap().0.push(order[i]);
}
}
result
@@ -54,8 +54,17 @@
//! that would put a global item into a chunk loaded by a chunk group not currently loading that
//! item is treated as `+infinity` cost.
use std::{
sync::{
LazyLock,
atomic::{AtomicU64, Ordering},
},
time::{SystemTime, UNIX_EPOCH},
};
use anyhow::Result;
use indexmap::map::Entry;
use petgraph::graph::NodeIndex;
use rustc_hash::FxHashSet;
use tracing::{Instrument, instrument};
use turbo_tasks::{FxIndexMap, FxIndexSet, ResolvedVc, TryJoinIterExt, Vc};
@@ -65,7 +74,7 @@ use crate::{
ChunkItemBatchWithAsyncModuleInfo, ChunkItemWithAsyncModuleInfo, ChunkType,
ChunkableModule, ChunkingContext, chunk_item_batch::attach_async_info_to_chunkable_module,
},
module::{StyleModule, StyleType},
module::{Module, StyleModule, StyleType},
module_graph::{
GraphTraversalAction, ModuleGraph,
module_batch::ModuleOrBatch,
@@ -145,6 +154,22 @@ pub async fn compute_style_groups_graph(
)
});
// Optional debug dump controlled by `TURBOPACK_DEBUG_CSS_CHUNKING`. Failures here are
// logged and otherwise swallowed so a debug toggle never breaks the build.
if *DEBUG_DUMP_ENABLED
&& let Err(err) = write_debug_dump(
&chunk_groups,
&modules_in_order,
&module_data,
&global_order,
&chunks,
)
.instrument(tracing::trace_span!("debug_dump"))
.await
{
eprintln!("TURBOPACK_DEBUG_CSS_CHUNKING: failed to write debug dump: {err:?}");
}
// 4. Assemble the result. Each multi-item chunk becomes a `ChunkItemBatch`; singletons get a
// `batch = None` entry so the production sort still places them at the right `order`.
assemble_style_groups(&chunks, &module_data)
@@ -152,8 +177,113 @@ pub async fn compute_style_groups_graph(
.await
}
static DEBUG_DUMP_ENABLED: LazyLock<bool> =
LazyLock::new(|| match std::env::var("TURBOPACK_DEBUG_CSS_CHUNKING") {
Ok(v) => !v.is_empty() && v != "0" && !v.eq_ignore_ascii_case("false"),
Err(_) => false,
});
/// Serialize a split cost metric for the debug dump. `serde_json` renders non-finite floats as
/// `null`, which would be indistinguishable from the genuine `None` of the last chunk, so the
/// non-finite cases are emitted as strings instead.
fn cost_to_json(cost: Option<f32>) -> serde_json::Value {
match cost {
None => serde_json::Value::Null,
Some(c) if c.is_finite() => serde_json::json!(c),
Some(c) if c == f32::NEG_INFINITY => serde_json::json!("-Infinity"),
Some(c) if c == f32::INFINITY => serde_json::json!("Infinity"),
Some(_) => serde_json::json!("NaN"),
}
}
/// Write a JSON snapshot of the inputs and outputs of the graph-based CSS chunker to the
/// current working directory. Each invocation produces a uniquely named file so concurrent or
/// repeated computations don't overwrite each other.
async fn write_debug_dump(
chunk_groups: &[Vec<usize>],
modules: &[StyleModuleRef],
module_data: &[ModuleData],
global_order: &[NodeIndex],
chunks: &[(Vec<usize>, Option<f32>)],
) -> Result<()> {
// Resolve `ident_string()` for every module up front. Done in parallel to keep this off the
// critical path even on graphs with thousands of CSS modules.
let ident_strings: Vec<String> = modules
.iter()
.map(async |m| -> Result<String> {
Ok(m.chunkable.ident_string().await?.as_str().to_owned())
})
.try_join()
.await?;
let ident = |id: usize| ident_strings[id].as_str();
let chunk_groups_json: Vec<Vec<&str>> = chunk_groups
.iter()
.map(|g| g.iter().map(|&id| ident(id)).collect())
.collect();
// Each chunk carries the cost-delta of merging it with the following chunk (`None`/`null` for
// the last chunk). A finite value is the surviving split metric; `"Infinity"` marks a boundary
// a hard constraint forbade merging across.
let mut chunks_json: Vec<serde_json::Value> = chunks
.iter()
.flat_map(|(chunk, merge_cost_to_next)| {
[
serde_json::json!(chunk.iter().map(|&id| ident(id)).collect::<Vec<_>>()),
serde_json::json!(cost_to_json(*merge_cost_to_next)),
]
})
.collect();
// last chunk has no merge cost
chunks_json.pop();
let global_order_flat_json: Vec<&str> = global_order.iter().map(|n| ident(n.index())).collect();
let modules_json: Vec<serde_json::Value> = modules
.iter()
.enumerate()
.map(|(i, _)| {
serde_json::json!({
"ident": ident(i),
"size": module_data[i].size,
"style_type": match module_data[i].style_type {
StyleType::GlobalStyle => "GlobalStyle",
StyleType::IsolatedStyle => "IsolatedStyle",
},
})
})
.collect();
let dump = serde_json::json!({
"chunk_groups": chunk_groups_json,
"global_order": global_order_flat_json,
"chunks": chunks_json,
"modules": modules_json,
});
let now_ms = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis())
.unwrap_or(0);
static COUNTER: AtomicU64 = AtomicU64::new(0);
let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
let path =
std::env::current_dir()?.join(format!("turbopack-css-chunking-debug-{now_ms}-{seq}.json"));
let bytes = serde_json::to_vec_pretty(&dump)?;
std::fs::write(&path, &bytes)?;
eprintln!(
"TURBOPACK_DEBUG_CSS_CHUNKING: wrote {} bytes to {}",
bytes.len(),
path.display()
);
Ok(())
}
async fn assemble_style_groups(
chunks: &[Vec<usize>],
chunks: &[(Vec<usize>, Option<f32>)],
module_data: &[ModuleData],
) -> Result<Vc<StyleGroups>> {
let mut shared_chunk_items: FxIndexMap<ChunkItemWithAsyncModuleInfo, StyleItemInfo> =
@@ -173,7 +303,7 @@ async fn assemble_style_groups(
order_counter += 1;
};
for chunk in chunks {
for (chunk, _cost) in chunks {
if chunk.is_empty() {
continue;
}
@@ -95,6 +95,8 @@ fn split_simple(
request_cost: f32,
) -> Vec<Vec<usize>> {
let count = global_order.len();
// The cost-to-next metric on each tuple is exercised by the production dump, not these
// structural assertions, so drop it and keep the bare chunk lists.
split_into_chunks(
global_order,
chunk_groups,
@@ -105,6 +107,9 @@ fn split_simple(
0.0,
0,
)
.into_iter()
.map(|(chunk, _cost)| chunk)
.collect()
}
// ---------------------------------------------------------------------------