Repository: denoland/deno
Pull request: perf(install): split tarball extraction into decompress + write, limit write concurrency

Split tarball extraction into a CPU-bound decompression phase and an I/O-bound filesystem write phase. Gate the write phase with a semaphore to reduce fs contention.

This is on a create-next-app project.
```
❯ hyperfine --warmup 1 --prepare "deno clean; rm -rf node_modules deno.lock" "deno install" "~/Documents/Code/deno/target/release-lite/deno install"
Benchmark 1: deno install
  Time (mean ± σ):     13.662 s ±  0.823 s    [User: 3.586 s, System: 7.008 s]
  Range (min … max):   12.364 s … 14.531 s    10 runs

Benchmark 2: ~/Documents/Code/deno/target/release-lite/deno install
  Time (mean ± σ):     12.889 s ±  0.926 s    [User: 2.524 s, System: 3.318 s]
  Range (min … max):   11.487 s … 14.400 s    10 runs

Summary
  ~/Documents/Code/deno/target/release-lite/deno install ran
    1.06 ± 0.10 times faster than deno install
```
Note both wall time and especially system time is decreased, since it's not trying to write a ton of files at the same time
<!--
Before submitting a PR, please read https://docs.deno.com/runtime/manual/references/contributing

1. Give the PR a descriptive title.

  Examples of good title:
    - fix(std/http): Fix race condition in server
    - docs(console): Update docstrings
    - feat(doc): Handle nested reexports

  Examples of bad title:
    - fix #7123
    - update docs
    - fix bugs

2. Ensure there is a related issue and it is referenced in the PR text.
3. Ensure there are tests that cover the changes.
4. Ensure `cargo test` passes.
5. Ensure `./tools/format.js` passes without changing files.
6. Ensure `./tools/lint.js` passes.
7. Open as a draft PR if your work is still in progress. The CI won't run
   all steps, but you can add '[ci]' to a commit message to force it to.
8. If you would like to run the benchmarks on the CI, add the 'ci-bench' label.
-->


Changed files:
filename: Cargo.lock
patch: @@ -2812,6 +2812,7 @@ dependencies = [
  "tar",
  "tempfile",
  "thiserror 2.0.12",
+ "tokio",
  "url",
 ]
 

filename: libs/npm_cache/Cargo.toml
patch: @@ -40,6 +40,7 @@ url.workspace = true
 deno_error = { workspace = true, features = ["serde", "serde_json", "tokio"] }
 deno_unsync = { workspace = true, features = ["tokio"] }
 aws-lc-rs.workspace = true
+tokio = { workspace = true, features = ["sync"] }
 
 [target.'cfg(target_arch = "wasm32")'.dependencies]
 flate2 = { workspace = true, features = ["rust_backend"] }

filename: libs/npm_cache/tarball.rs
patch: @@ -21,7 +21,8 @@ use crate::remote::maybe_auth_header_value_for_npm_registry;
 use crate::rt::MultiRuntimeAsyncValueCreator;
 use crate::rt::spawn_blocking;
 use crate::tarball_extract::TarballExtractionMode;
-use crate::tarball_extract::verify_and_extract_tarball;
+use crate::tarball_extract::verify_and_decompress_tarball;
+use crate::tarball_extract::write_extracted_tarball;
 
 type LoadResult = Result<(), Arc<JsErrorBox>>;
 type LoadFuture = LocalBoxFuture<'static, LoadResult>;
@@ -36,6 +37,14 @@ enum MemoryCacheItem {
   Cached,
 }
 
+/// Maximum number of concurrent filesystem write operations for tarball extraction.
+/// Filesystem operations (open/write/close per file) dominate extraction time,
+/// and on macOS APFS has an internal mutex that makes highly parallel filesystem
+/// operations contend heavily. Limiting concurrency reduces this contention.
+/// Decompression (CPU-bound) is not gated by this limit.
+const MAX_CONCURRENT_FS_WRITES: usize =
+  if cfg!(target_os = "macos") { 4 } else { 128 };
+
 /// Coordinates caching of tarballs being loaded from
 /// the npm registry.
 ///
@@ -47,6 +56,8 @@ pub struct TarballCache<THttpClient: NpmCacheHttpClient, TSys: NpmCacheSys> {
   sys: TSys,
   npmrc: Arc<ResolvedNpmRc>,
   memory_cache: Mutex<HashMap<PackageNv, MemoryCacheItem>>,
+  #[cfg(not(target_arch = "wasm32"))]
+  fs_write_semaphore: tokio::sync::Semaphore,
 
   reporter: Option<Arc<dyn TarballCacheReporter>>,
 }
@@ -82,6 +93,8 @@ impl<THttpClient: NpmCacheHttpClient, TSys: NpmCacheSys>
       sys,
       npmrc,
       memory_cache: Default::default(),
+      #[cfg(not(target_arch = "wasm32"))]
+      fs_write_semaphore: tokio::sync::Semaphore::new(MAX_CONCURRENT_FS_WRITES),
       reporter,
     }
   }
@@ -238,15 +251,33 @@ impl<THttpClient: NpmCacheHttpClient, TSys: NpmCacheSys>
           };
           let dist = dist.clone();
           let package_nv = package_nv.clone();
-          spawn_blocking(move || verify_and_extract_tarball(
+          // Phase 1: verify integrity + decompress (CPU-bound, no concurrency limit)
+          let tar_data = spawn_blocking(move || {
+            verify_and_decompress_tarball(&package_nv, &bytes, &dist)
+          })
+          .await
+          .map_err(JsErrorBox::from_err)?
+          .map_err(JsErrorBox::from_err)?;
+          // Phase 2: write to disk (I/O-bound, limited concurrency to
+          // avoid filesystem contention — especially on macOS APFS)
+          #[cfg(not(target_arch = "wasm32"))]
+          let _permit = tarball_cache
+            .fs_write_semaphore
+            .acquire()
+            .await
+            .map_err(|e| JsErrorBox::generic(e.to_string()))?;
+
+          spawn_blocking(move || {
+            write_extracted_tarball(
               &sys,
-              &package_nv,
-              &bytes,
-              &dist,
+              &tar_data,
               &package_folder,
               extraction_mode,
-            ))
-          .await.map_err(JsErrorBox::from_err)?.map_err(JsErrorBox::from_err)
+            )
+          })
+          .await
+          .map_err(JsErrorBox::from_err)?
+          .map_err(JsErrorBox::from_err)
         }
         None => {
           Err(JsErrorBox::generic(format!("Could not find npm package tarball at: {}", dist.tarball)))

filename: libs/npm_cache/tarball_extract.rs
patch: @@ -2,6 +2,7 @@
 
 use std::collections::HashSet;
 use std::io::ErrorKind;
+use std::io::Read;
 use std::path::Path;
 use std::path::PathBuf;
 
@@ -75,7 +76,26 @@ pub enum VerifyAndExtractTarballError {
   MoveFailed(std::io::Error),
 }
 
-pub fn verify_and_extract_tarball(
+/// Verifies the tarball integrity and decompresses gzip to raw tar bytes.
+/// This is CPU-bound work that can safely run in parallel.
+pub fn verify_and_decompress_tarball(
+  package_nv: &PackageNv,
+  data: &[u8],
+  dist_info: &NpmPackageVersionDistInfo,
+) -> Result<Vec<u8>, VerifyAndExtractTarballError> {
+  verify_tarball_integrity(package_nv, data, &dist_info.integrity())?;
+  let mut decoder = GzDecoder::new(data);
+  let mut decompressed = Vec::new();
+  decoder
+    .read_to_end(&mut decompressed)
+    .map_err(ExtractTarballError::from)?;
+  Ok(decompressed)
+}
+
+/// Writes already-decompressed raw tar bytes to disk.
+/// This is I/O-bound work that benefits from limited concurrency
+/// to avoid filesystem contention.
+pub fn write_extracted_tarball(
   sys: &(
      impl FsCanonicalize
      + FsCreateDirAll
@@ -87,21 +107,17 @@ pub fn verify_and_extract_tarball(
      + SystemRandom
      + ThreadSleep
    ),
-  package_nv: &PackageNv,
-  data: &[u8],
-  dist_info: &NpmPackageVersionDistInfo,
+  tar_data: &[u8],
   output_folder: &Path,
   extraction_mode: TarballExtractionMode,
 ) -> Result<(), VerifyAndExtractTarballError> {
-  verify_tarball_integrity(package_nv, data, &dist_info.integrity())?;
-
   match extraction_mode {
     TarballExtractionMode::Overwrite => {
-      extract_tarball(sys, data, output_folder).map_err(Into::into)
+      extract_tarball(sys, tar_data, output_folder).map_err(Into::into)
     }
     TarballExtractionMode::SiblingTempDir => {
       let temp_dir = deno_path_util::get_atomic_path(sys, output_folder);
-      extract_tarball(sys, data, &temp_dir)?;
+      extract_tarball(sys, tar_data, &temp_dir)?;
       rename_with_retries(sys, &temp_dir, output_folder)
         .map_err(VerifyAndExtractTarballError::MoveFailed)
     }
@@ -266,9 +282,10 @@ pub enum ExtractTarballError {
   NotInOutputDirectory(PathBuf),
 }
 
+/// Extracts raw (already decompressed) tar bytes to the output folder.
 fn extract_tarball(
   sys: &(impl FsCanonicalize + FsCreateDirAll + FsOpen + FsRemoveFile),
-  data: &[u8],
+  tar_data: &[u8],
   output_folder: &Path,
 ) -> Result<(), ExtractTarballError> {
   sys
@@ -286,8 +303,7 @@ fn extract_tarball(
         operation: IoErrorOperation::Canonicalizing,
         source,
       })?;
-  let tar = GzDecoder::new(data);
-  let mut archive = Archive::new(tar);
+  let mut archive = Archive::new(tar_data);
   archive.set_overwrite(true);
   archive.set_preserve_permissions(true);
   let mut created_dirs = HashSet::new();