vercel/next.js · #100475

Cache bundle analysis per output file

wbinnssmith · merged Oct 23, 20265 files · 244 + / 156 −
crates/next-api/src/analyze.rs56 + / 72 −
@@ -1,30 +1,27 @@ mod groups; mod membership;+mod output_file; -use std::{borrow::Cow, io::Write};+use std::io::Write;  use anyhow::Result; use bincode::{Decode, Encode}; use byteorder::{BE, WriteBytesExt};-use either::Either;-use membership::output_chunk_modules; use next_core::app_structure::FileSystemPathVec;+use output_file::{AnalyzedChunkPart, analyze_output_file, analyze_traced_file}; use rustc_hash::{FxHashMap, FxHashSet}; use serde::Serialize; use turbo_rcstr::RcStr; use turbo_tasks::{-    FxIndexSet, JoinIterExt, NonLocalValue, ResolvedVc, TryFlatJoinIterExt, ValueToString,-    ValueToStringRef, Vc,+    FxIndexSet, JoinIterExt, NonLocalValue, ResolvedVc, TryFlatJoinIterExt, TryJoinIterExt,+    ValueToString, ValueToStringRef, Vc, }; use turbo_tasks_fs::{     File, FileContent, FileSystemPath,     rope::{Rope, RopeBuilder}, }; use turbo_tasks_hash::hash_xxh3_hash64;-use turbopack_analyze::split_chunk::{split_output_asset_into_parts, split_traced_file_into_parts};-use turbopack_browser::ecmascript::EcmascriptBrowserChunk; use turbopack_core::{-    SOURCE_URL_PROTOCOL,     asset::{Asset, AssetContent},     chunk::{ChunkingType, TracedMode},     module::Module,@@ -254,11 +251,12 @@ struct ModulesDataHeader {     pub traced_module_dependencies: EdgesDataReference, } -#[derive(Clone, Copy, Serialize)]+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, NonLocalValue, Encode, Decode, Serialize)] #[serde(rename_all = "snake_case")] enum AnalyzeOutputFileCoverage {     Exact,     Unsupported,+    #[default]     NotAChunk, } @@ -687,7 +685,8 @@ pub async fn analyze_output_assets(         },     };     let route_entries = route_entries.await?.iter().cloned().collect();-    let module_index = analyze_module_index(module_graph).await?;+    let module_index_vc = analyze_module_index(module_graph);+    let module_index = module_index_vc.await?;      let mut builder =         AnalyzeDataBuilder::new(route_entries, module_index.module_index_hash.clone());@@ -696,78 +695,63 @@ pub async fn analyze_output_assets(     let mut candidates = Vec::new();     let mut browser_chunks = FxHashSet::default(); -    let prefix = format!("{SOURCE_URL_PROTOCOL}///");--    // Process the output assets and extract chunk parts.-    // Also creates sources for the chunk parts.-    for asset in output_assets-        .await?+    let output_assets = output_assets.await?;+    let traced_files = traced_files.await?;+    // One call per file: per-route fan-out over shared assets dominates analysis+    // through task-graph bookkeeping, not the work behind each call.+    let files = output_assets         .iter()-        .copied()-        .map(Either::Left)-        .chain(traced_files.await?.iter().cloned().map(Either::Right))-    {-        let file_system_path = match &asset {-            Either::Left(asset) => Either::Left(asset.path().await?),-            Either::Right(path) => Either::Right(path),-        };-        let path = match &file_system_path {-            Either::Left(path) => &path.path,-            Either::Right(path) => &path.path,-        };-        if path.ends_with(".map") || path.ends_with(".nft.json") {-            // Skip source maps.-            continue;-        }--        let filename = match &file_system_path {-            Either::Left(path) => path.to_string_ref().await?,-            Either::Right(path) => path.to_string_ref().await?,-        };+        .map(|&asset| analyze_output_file(*asset, module_index_vc))+        .chain(+            traced_files+                .iter()+                .map(|path| analyze_traced_file(path.clone())),+        )+        .try_join()+        .await?; -        let is_client = filename.starts_with("[client-fs]/");+    for file in files.iter().filter_map(|file| (**file).as_ref()) {+        let is_client = file.filename.starts_with("[client-fs]/");         let output_file_index = builder.add_output_file(AnalyzeOutputFile {-            filename: filename.clone(),+            filename: file.filename.clone(),         });-        if let Either::Left(asset) = &asset {+        if let Some(emitted) = &file.emitted {+            let membership = &emitted.membership;             asset_indices-                .entry(*asset)+                .entry(emitted.asset)                 .or_default()                 .push(output_file_index);-            if ResolvedVc::try_downcast_type::<EcmascriptBrowserChunk>(*asset).is_some() {+            if emitted.is_browser_chunk {                 browser_chunks.insert(output_file_index);             }-            let (indices, coverage, loads, async_loaders) =-                output_chunk_modules(*asset, &filename, &module_index, output_file_index).await?;-            candidates.extend(loads);-            let file = &mut builder.output_files[output_file_index as usize];-            file.module_indices.extend(indices);-            file.module_indices.sort_unstable();-            file.module_indices.dedup();-            file.async_loader_indices.extend(async_loaders);-            file.async_loader_indices.sort_unstable();-            file.async_loader_indices.dedup();-            file.module_coverage = coverage;+            candidates.extend(+                membership+                    .load_targets+                    .iter()+                    .map(|load| ChunkLoadCandidate {+                        source: output_file_index,+                        target: load.target,+                        trigger_module_index: load.trigger_module_index,+                    }),+            );+            let output = &mut builder.output_files[output_file_index as usize];+            output.module_indices.extend(&membership.module_indices);+            output.module_indices.sort_unstable();+            output.module_indices.dedup();+            output+                .async_loader_indices+                .extend(&membership.async_loader_indices);+            output.async_loader_indices.sort_unstable();+            output.async_loader_indices.dedup();+            output.module_coverage = membership.coverage;         }-        let chunk_parts = match asset {-            Either::Left(asset) => split_output_asset_into_parts(*asset).await?,-            Either::Right(path) => split_traced_file_into_parts(path).await?,-        };-        for chunk_part in &chunk_parts {-            let decoded_source = urlencoding::decode(&chunk_part.source)?;-            let source = if let Some(stripped) = decoded_source.strip_prefix(&prefix) {-                Cow::Borrowed(stripped)-            } else if decoded_source.starts_with('[') && decoded_source.contains("]/") {-                decoded_source-            } else {-                Cow::Owned(format!(-                    "[project]/{}",-                    decoded_source.trim_start_matches("../")-                ))-            };-            let source_index = builder.ensure_source(&source).1;-            let size = chunk_part.real_size + chunk_part.unaccounted_size;-            let compressed_size = chunk_part.get_compressed_size().await?.unwrap_or(size);+        for &AnalyzedChunkPart {+            ref source,+            size,+            compressed_size,+        } in &file.chunk_parts+        {+            let source_index = builder.ensure_source(source).1;             summary.size += u64::from(size);             summary.compressed_size += u64::from(compressed_size);             if is_client {
crates/next-api/src/analyze/membership.rs32 + / 35 −
@@ -2,9 +2,10 @@ //! snapshot's ordered module index. Unknown wrappers remain unsupported.  use anyhow::{Context, Result, anyhow};+use bincode::{Decode, Encode}; use rustc_hash::FxHashMap; use turbo_rcstr::RcStr;-use turbo_tasks::{FxIndexSet, ResolvedVc, ValueToString, Vc};+use turbo_tasks::{FxIndexSet, NonLocalValue, ResolvedVc, ValueToString, Vc}; use turbopack_browser::ecmascript::{     EcmascriptBrowserChunk, EcmascriptBrowserRuntimeChunk, EcmascriptBrowserSingleEntryChunk,     EcmascriptBrowserWorkerEntrypoint,@@ -23,7 +24,22 @@ use turbopack_ecmascript::{ }; use turbopack_nodejs::{EcmascriptBuildNodeChunk, EcmascriptBuildNodeRuntimeChunk}; -use crate::analyze::{AnalyzeModuleIndex, AnalyzeOutputFileCoverage, ChunkLoadCandidate};+use crate::analyze::{AnalyzeModuleIndex, AnalyzeOutputFileCoverage};++#[derive(Clone, Debug, PartialEq, Eq, NonLocalValue, Encode, Decode)]+pub(super) struct ChunkLoadTarget {+    pub target: ResolvedVc<Box<dyn OutputAsset>>,+    /// Index of the dynamically imported target module in the analyzer's shared module index.+    pub trigger_module_index: u32,+}++#[derive(Clone, Debug, Default, PartialEq, Eq, NonLocalValue, Encode, Decode)]+pub(super) struct OutputChunkModules {+    pub module_indices: Vec<u32>,+    pub coverage: AnalyzeOutputFileCoverage,+    pub load_targets: Vec<ChunkLoadTarget>,+    pub async_loader_indices: Vec<u32>,+}  async fn insert_chunk_item(     module: Vc<Box<dyn Module>>,@@ -78,8 +94,7 @@ fn insert_chunk_item_ident( async fn chunk_item_load_candidates(     item: ResolvedVc<Box<dyn turbopack_ecmascript::chunk::EcmascriptChunkItem>>,     index: &AnalyzeModuleIndex,-    source: u32,-) -> Result<Vec<ChunkLoadCandidate>> {+) -> Result<Vec<ChunkLoadTarget>> {     let module = item.module().to_resolved().await?;     let trigger = if let Some(loader) = ResolvedVc::try_downcast_type::<AsyncLoaderModule>(module) {         loader.await?.inner@@ -101,8 +116,7 @@ async fn chunk_item_load_candidates(     Ok(assets         .iter()         .copied()-        .map(|target| ChunkLoadCandidate {-            source,+        .map(|target| ChunkLoadTarget {             target,             trigger_module_index,         })@@ -112,22 +126,16 @@ async fn chunk_item_load_candidates( /// Enumerate known browser/Node.js JS and CSS chunk items; unknown wrappers remain unsupported. pub(super) async fn output_chunk_modules(     asset: ResolvedVc<Box<dyn OutputAsset>>,-    filename: &str,+    filename: &RcStr,     module_index: &AnalyzeModuleIndex,-    output_file_index: u32,-) -> Result<(-    Vec<u32>,-    AnalyzeOutputFileCoverage,-    Vec<ChunkLoadCandidate>,-    Vec<u32>,-)> {+) -> Result<OutputChunkModules> {     if ResolvedVc::try_downcast_type::<EcmascriptBrowserRuntimeChunk>(asset).is_some()         || ResolvedVc::try_downcast_type::<EcmascriptBuildNodeRuntimeChunk>(asset).is_some()         || ResolvedVc::try_downcast_type::<SingleFileEcmascriptOutput>(asset).is_some()         || ResolvedVc::try_downcast_type::<EcmascriptBrowserSingleEntryChunk>(asset).is_some()         || ResolvedVc::try_downcast_type::<EcmascriptBrowserWorkerEntrypoint>(asset).is_some()     {-        return Ok((vec![], AnalyzeOutputFileCoverage::NotAChunk, vec![], vec![]));+        return Ok(OutputChunkModules::default());     }     let mut indices = FxIndexSet::default();     let mut async_loaders = FxIndexSet::default();@@ -157,14 +165,8 @@ pub(super) async fn output_chunk_modules(                     )                     .await                     .with_context(|| format!("Analyzing output {filename}"))?;-                    candidates.extend(-                        chunk_item_load_candidates(-                            item.chunk_item,-                            module_index,-                            output_file_index,-                        )-                        .await?,-                    );+                    candidates+                        .extend(chunk_item_load_candidates(item.chunk_item, module_index).await?);                 }                 EcmascriptChunkItemOrBatchWithAsyncInfo::Batch(batch) => {                     for item in &batch.await?.chunk_items {@@ -177,12 +179,7 @@ pub(super) async fn output_chunk_modules(                         .await                         .with_context(|| format!("Analyzing output {filename}"))?;                         candidates.extend(-                            chunk_item_load_candidates(-                                item.chunk_item,-                                module_index,-                                output_file_index,-                            )-                            .await?,+                            chunk_item_load_candidates(item.chunk_item, module_index).await?,                         );                     }                 }@@ -204,19 +201,19 @@ pub(super) async fn output_chunk_modules(         // Other emitted JS/CSS wrappers cannot enumerate their module members.         enumerated = false;     } else {-        return Ok((vec![], AnalyzeOutputFileCoverage::NotAChunk, vec![], vec![]));+        return Ok(OutputChunkModules::default());     }     let coverage = if enumerated {         AnalyzeOutputFileCoverage::Exact     } else {         AnalyzeOutputFileCoverage::Unsupported     };-    Ok((-        indices.into_iter().collect(),+    Ok(OutputChunkModules {+        module_indices: indices.into_iter().collect(),         coverage,-        candidates,-        async_loaders.into_iter().collect(),-    ))+        load_targets: candidates,+        async_loader_indices: async_loaders.into_iter().collect(),+    }) }  #[cfg(test)]
crates/next-api/src/analyze/output_file.rsadded116 + / 0 −
@@ -0,0 +1,116 @@+//! One emitted or traced file's analysis, cached per file and shared by every+//! route that includes it.++use anyhow::Result;+use bincode::{Decode, Encode};+use turbo_rcstr::RcStr;+use turbo_tasks::{NonLocalValue, ResolvedVc, ValueToStringRef, Vc};+use turbo_tasks_fs::FileSystemPath;+use turbopack_analyze::split_chunk::{+    ChunkPart, split_output_asset_into_parts, split_traced_file_into_parts,+};+use turbopack_browser::ecmascript::EcmascriptBrowserChunk;+use turbopack_core::{SOURCE_URL_PROTOCOL, output::OutputAsset};++use crate::analyze::{+    AnalyzeModuleIndex,+    membership::{OutputChunkModules, output_chunk_modules},+};++#[derive(Clone, Debug, PartialEq, Eq, NonLocalValue, Encode, Decode)]+pub(super) struct AnalyzedChunkPart {+    /// Normalized source path, ready for the route's source tree.+    pub source: RcStr,+    pub size: u32,+    pub compressed_size: u32,+}++#[derive(Clone, Debug, PartialEq, Eq, NonLocalValue, Encode, Decode)]+pub(super) struct EmittedChunk {+    pub asset: ResolvedVc<Box<dyn OutputAsset>>,+    pub is_browser_chunk: bool,+    pub membership: OutputChunkModules,+}++#[derive(Clone, Debug, PartialEq, Eq, NonLocalValue, Encode, Decode)]+pub(super) struct AnalyzedOutputFile {+    pub filename: RcStr,+    pub chunk_parts: Vec<AnalyzedChunkPart>,+    /// `None` for traced files.+    pub emitted: Option<EmittedChunk>,+}++/// `None` for source maps and trace manifests, which are not reported.+#[turbo_tasks::value(transparent)]+pub(super) struct OptionAnalyzedOutputFile(Option<AnalyzedOutputFile>);++#[turbo_tasks::function]+pub(super) async fn analyze_output_file(+    asset: ResolvedVc<Box<dyn OutputAsset>>,+    module_index: Vc<AnalyzeModuleIndex>,+) -> Result<Vc<OptionAnalyzedOutputFile>> {+    let path = asset.path().await?;+    if !is_reported_file(&path) {+        return Ok(Vc::cell(None));+    }+    let filename = path.to_string_ref().await?;+    let membership = output_chunk_modules(asset, &filename, &*module_index.await?).await?;+    let chunk_parts = analyzed_chunk_parts(&split_output_asset_into_parts(*asset).await?).await?;+    Ok(Vc::cell(Some(AnalyzedOutputFile {+        filename,+        chunk_parts,+        emitted: Some(EmittedChunk {+            asset,+            is_browser_chunk: ResolvedVc::try_downcast_type::<EcmascriptBrowserChunk>(asset)+                .is_some(),+            membership,+        }),+    })))+}++#[turbo_tasks::function]+pub(super) async fn analyze_traced_file(+    path: FileSystemPath,+) -> Result<Vc<OptionAnalyzedOutputFile>> {+    if !is_reported_file(&path) {+        return Ok(Vc::cell(None));+    }+    Ok(Vc::cell(Some(AnalyzedOutputFile {+        filename: path.to_string_ref().await?,+        chunk_parts: analyzed_chunk_parts(&split_traced_file_into_parts(path).await?).await?,+        emitted: None,+    })))+}++fn is_reported_file(path: &FileSystemPath) -> bool {+    !(path.path.ends_with(".map") || path.path.ends_with(".nft.json"))+}++async fn analyzed_chunk_parts(chunk_parts: &[ChunkPart]) -> Result<Vec<AnalyzedChunkPart>> {+    let mut analyzed = Vec::with_capacity(chunk_parts.len());+    for part in chunk_parts {+        let size = part.real_size + part.unaccounted_size;+        analyzed.push(AnalyzedChunkPart {+            source: normalize_source(&part.source)?,+            size,+            compressed_size: part.get_compressed_size().await?.unwrap_or(size),+        });+    }+    Ok(analyzed)+}++fn normalize_source(raw: &str) -> Result<RcStr> {+    let decoded = urlencoding::decode(raw)?;+    Ok(+        if let Some(path) = decoded+            .strip_prefix(SOURCE_URL_PROTOCOL.as_str())+            .and_then(|rest| rest.strip_prefix("///"))+        {+            path.into()+        } else if decoded.starts_with('[') && decoded.contains("]/") {+            decoded.as_ref().into()+        } else {+            format!("[project]/{}", decoded.trim_start_matches("../")).into()+        },+    )+}
turbopack/crates/turbopack-analyze/src/split_chunk.rs13 + / 19 −
@@ -60,36 +60,30 @@ impl ChunkPart {     } } -#[turbo_tasks::value(transparent)]-#[derive(Debug)]-pub struct ChunkParts(Vec<ChunkPart>);--#[turbo_tasks::function]-pub async fn split_traced_file_into_parts(path: FileSystemPath) -> Result<Vc<ChunkParts>> {+pub async fn split_traced_file_into_parts(path: FileSystemPath) -> Result<Vec<ChunkPart>> {     let source = FileSource::new(path.clone());     let content = source.content().await?;     let AssetContent::File(file_content) = &*content else {-        return Ok(Vc::cell(vec![]));+        return Ok(vec![]);     };     let FileContent::Content(content) = &*file_content.await? else {-        return Ok(Vc::cell(vec![]));+        return Ok(vec![]);     };     let content = content.content();     let lines_vc = file_content.lines().to_resolved().await?;      self_mapped(path.to_string_ref().await?, content, lines_vc).await } -#[turbo_tasks::function] pub async fn split_output_asset_into_parts(     asset: Vc<Box<dyn OutputAsset>>,-) -> Result<Vc<ChunkParts>> {+) -> Result<Vec<ChunkPart>> {     let content = asset.content().await?;     let AssetContent::File(file_content) = &*content else {-        return Ok(Vc::cell(vec![]));+        return Ok(vec![]);     };     let FileContent::Content(content) = &*file_content.await? else {-        return Ok(Vc::cell(vec![]));+        return Ok(vec![]);     };     let content = content.content();     let lines_vc = file_content.lines().to_resolved().await?;@@ -397,37 +391,37 @@ pub async fn split_output_asset_into_parts(         }     } -    Ok(Vc::cell(chunk_parts.into_values().collect()))+    Ok(chunk_parts.into_values().collect()) }  pub async fn self_mapped(     path: RcStr,     content: &Rope,     lines: ResolvedVc<FileLinesContent>,-) -> Result<Vc<ChunkParts>> {+) -> Result<Vec<ChunkPart>> {     let len = content.len().try_into().unwrap_or(u32::MAX);-    Ok(Vc::cell(vec![ChunkPart {+    Ok(vec![ChunkPart {         source: path,         real_size: len,         unaccounted_size: 0,         ranges: vec![],         lines,-    }]))+    }]) }  async fn unaccounted(     asset: Vc<Box<dyn OutputAsset>>,     content: &Rope,     lines: ResolvedVc<FileLinesContent>,-) -> Result<Vc<ChunkParts>> {+) -> Result<Vec<ChunkPart>> {     let len = content.len().try_into().unwrap_or(u32::MAX);-    Ok(Vc::cell(vec![ChunkPart {+    Ok(vec![ChunkPart {         source: asset.path().to_string().owned().await?,         real_size: 0,         unaccounted_size: len,         ranges: vec![],         lines,-    }]))+    }]) }  fn append_content_between(
turbopack/crates/turbopack-analyze/tests/split_chunk.rs27 + / 30 −
@@ -5,12 +5,12 @@ use anyhow::Result; use serde_json::json; use turbo_rcstr::rcstr;-use turbo_tasks::{OperationVc, ResolvedVc, Vc};+use turbo_tasks::{ResolvedVc, Vc}; use turbo_tasks_fs::{     File, FileContent, FileSystem, FileSystemPath, VirtualFileSystem, rope::Rope, }; use turbo_tasks_testing::{Registration, register, run_once};-use turbopack_analyze::split_chunk::{ChunkPartRange, ChunkParts, split_output_asset_into_parts};+use turbopack_analyze::split_chunk::{ChunkPart, ChunkPartRange, split_output_asset_into_parts}; use turbopack_core::{     asset::{Asset, AssetContent},     code_builder::{Code, CodeBuilder},@@ -55,13 +55,10 @@ async fn split_chunk() {         }         .resolved_cell(); -        #[turbo_tasks::function(operation, root)]-        fn split_parts_operation(asset: ResolvedVc<TestAsset>) -> Vc<ChunkParts> {-            split_output_asset_into_parts(Vc::upcast(*asset))-        }--        let parts_op = split_parts_operation(asset);-        let parts = parts_op.read_strongly_consistent().await?;+        let result = split_parts_operation(asset)+            .read_strongly_consistent()+            .await?;+        let parts = &result.parts;          assert_eq!(parts.len(), 2); @@ -96,34 +93,34 @@ async fn split_chunk() {             }]         ); -        #[turbo_tasks::function(operation, root)]-        async fn compressed_size_operation(-            parts: OperationVc<ChunkParts>,-            index: usize,-        ) -> Result<Vc<u32>> {-            Ok(Vc::cell(-                parts.connect().await?[index]-                    .get_compressed_size()-                    .await?-                    .unwrap(),-            ))-        }--        let compressed_size_0 = compressed_size_operation(parts_op, 0)-            .read_strongly_consistent()-            .await?;-        let compressed_size_1 = compressed_size_operation(parts_op, 1)-            .read_strongly_consistent()-            .await?;-        assert_eq!(*compressed_size_0, 43);-        assert_eq!(*compressed_size_1, 28);+        assert_eq!(result.compressed_sizes, [Some(43), Some(28)]);          anyhow::Ok(())     })     .await     .unwrap() } +#[turbo_tasks::value]+struct SplitResult {+    parts: Vec<ChunkPart>,+    compressed_sizes: Vec<Option<u32>>,+}++#[turbo_tasks::function(operation, root)]+async fn split_parts_operation(asset: ResolvedVc<TestAsset>) -> Result<Vc<SplitResult>> {+    let parts = split_output_asset_into_parts(Vc::upcast(*asset)).await?;+    let mut compressed_sizes = Vec::with_capacity(parts.len());+    for part in &parts {+        compressed_sizes.push(part.get_compressed_size().await?);+    }+    Ok(SplitResult {+        parts,+        compressed_sizes,+    }+    .cell())+}+ #[turbo_tasks::value] struct TestAsset {     code: ResolvedVc<Code>,