Skip to content

perf(npm): index packuments with SIMD JSON scanner#35698

Merged
nathanwhit merged 3 commits into
denoland:mainfrom
nathanwhit:codex/lazy-npm-packuments
Jul 3, 2026
Merged

perf(npm): index packuments with SIMD JSON scanner#35698
nathanwhit merged 3 commits into
denoland:mainfrom
nathanwhit:codex/lazy-npm-packuments

Conversation

@nathanwhit

@nathanwhit nathanwhit commented Jul 2, 2026

Copy link
Copy Markdown
Member

Summary

This adds libs/fast-registry-json, a specialized npm packument indexer, and uses it when loading npm package info. The intent is to avoid eagerly deserializing every version manifest in large packuments when resolution usually only needs a small subset of them.

The new parser is not a general replacement for serde_json. It is a narrow indexer for registry JSON:

  • scan JSON quickly to find strings and structural operators
  • build an index of top-level packument fields Deno needs for resolution
  • keep byte ranges for each versions[version] manifest
  • lazily deserialize only selected version manifests with serde_json

SIMD Scanner

fast-registry-json is adapted from simdjson-style stage 1 scanning, but narrowed for npm registry packuments rather than general-purpose JSON parsing.

At a high level, it scans the input in 64-byte blocks, identifies string ranges and structural JSON characters, then feeds those tokens into a small packument-specific state machine. The state machine records only the data Deno needs for npm resolution: name, dist-tags, versions keys and byte ranges, time, _deno.etag, and compact trust evidence.

Target behavior:

  • aarch64 uses the NEON path when available via the target feature
  • x86_64 uses runtime dispatch once via OnceLock; SSSE3 gets the table-shuffle classifier, otherwise it falls back to comparison-based classification
  • x86_64 bitmask extraction uses SSE2 movemask, which is baseline for x86_64
  • other targets use the portable wide/comparison path

Unsupported SIMD targets still have a fallback path rather than requiring the fastest classifier.

Deno Integration

NpmPackageInfo::from_packument_slice() now uses the packument index directly. It parses the cheap top-level data eagerly:

  • package name
  • dist-tags
  • publish times for minimumDependencyAge
  • version keys and byte ranges
  • trust evidence (provenance, trustedPublisher, staged publish approver)

The versions map is now represented by NpmPackageVersionInfos, which can be either materialized or lazy. The lazy variant stores:

  • the original packument text in an Arc<str>
  • a Version -> Range<usize> map
  • a per-version OnceCell<Option<NpmPackageVersionInfo>>
  • indexed trust evidence

Resolution can iterate version keys, check publish times, apply trust policy, and then deserialize only the selected manifest. Existing paths that need all manifests can still iterate values or mutate the map; those paths materialize as needed.

Cache Changes

The npm cache path now writes slim packument bytes from the indexed source rather than deserializing the full packument and serializing it again.

The slim cache keeps the resolution-relevant fields:

  • dist-tags
  • versions
  • time
  • selected dependency/platform/deprecation/bin fields
  • hasInstallScript
  • trust evidence compactly projected back into the manifest shape
  • _deno.etag

This keeps minimumDependencyAge working for full packuments and avoids defeating laziness when saving the cache.

Benchmarks

Built with cargo build -p deno --profile release-lite.

Compared:

  • baseline: system canary /Users/nathanwhit/.deno/bin/deno
  • current: target/release-lite/deno

Command shape:

DENO_DIR=<per-binary-cache> NO_COLOR=true DENO_NO_UPDATE_CHECK=1 deno install

For each measured run, node_modules and deno.lock were removed. Each binary used a separate warmed DENO_DIR.

Targeted Fixtures

Fixture System mean Current mean Speedup
drizzle-orm only 107.8 ms 49.3 ms 2.19x
create-next-app fixture 607.5 ms 434.9 ms 1.40x

With lockfiles preserved, both fixtures were effectively neutral because Deno does not need to read packuments on that path.

../vlt-benchmarks Fixtures

Warm-cache, no-lockfile, run in place in ../vlt-benchmarks/fixtures/*:

Fixture System mean Current mean Speedup
astro 296.0 ms 231.2 ms 1.28x
next 548.0 ms 382.3 ms 1.43x
svelte 83.9 ms 58.7 ms 1.43x
vue 433.4 ms 313.8 ms 1.38x
run 157.3 ms 89.1 ms 1.76x
large 930.4 ms 936.2 ms 0.99x
babylon 2.248 s 2.035 s 1.10x

The large fixture is wall-time neutral because filesystem / node_modules work dominates, though the current branch used much less user CPU. The common app fixtures are consistently about 1.3-1.4x faster.

x86_64 Linux ../vlt-benchmarks Fixtures

Host: [email protected], x86_64 Broadwell, canary upgraded with deno upgrade canary.

Compared:

  • baseline: canary deno 2.9.1+4e8b2f4 (canary, release, x86_64-unknown-linux-gnu)
  • current: PR release-lite binary deno 2.9.1 (stable, release, x86_64-unknown-linux-gnu)

Command shape:

DENO_DIR=<per-binary-cache> deno install --quiet

Each binary used a separate warmed DENO_DIR populated by that binary. For each measured run, node_modules and deno.lock were removed, preserving normal node_modules behavior.

Fixture Canary mean Current mean Speedup
app 1.021 s 617.3 ms 1.65x
fixtures/astro 519.8 ms 318.9 ms 1.63x
fixtures/babylon 4.809 s 3.566 s 1.35x
fixtures/large 1.439 s 1.322 s 1.09x
fixtures/next 1.630 s 726.8 ms 2.24x
fixtures/run 416.1 ms 263.7 ms 1.58x
fixtures/svelte 254.0 ms 162.3 ms 1.56x
fixtures/vue 1.034 s 576.4 ms 1.79x

Full log: /home/bot/work/vlt-deno-install-all-fixtures-20260702-200452.log on the x64 benchmark host.

x86_64 Packument Index Classifier Check

Raw full registry packuments were downloaded directly from https://registry.npmjs.org/<pkg> and benchmarked with cargo bench -p fast-registry-json --bench benchmark -- bench_packument_index_. The optimized x86 path uses SSSE3 runtime dispatch; the fallback measurement temporarily forced the comparison classifier.

Package Full packument size SSSE3 median Fallback median Delta
client-only 2.7 KB 4.319 us 4.922 us fallback 1.14x slower
node-pty 517 KB 1.130 ms 1.272 ms 1.13x slower
drizzle-kit 2.7 MB 5.748 ms 6.845 ms 1.19x slower
next 30.9 MB 93.11 ms 104.0 ms 1.12x slower
drizzle-orm 64.4 MB 178.4 ms 206.1 ms 1.16x slower
@prisma/client 67.9 MB 193.3 ms 214.6 ms 1.11x slower

Caveats

  • Local aarch64 and x86_64 Linux results are both included above. The x86_64 host was shared/noisy, so treat small deltas as directional rather than exact.
  • This is a targeted packument indexer, not a validating JSON parser. Selected version manifests are still deserialized through serde_json.

Validation

  • cargo test -p fast-registry-json -p deno_npm -p deno_npm_cache
  • cargo clippy -p fast-registry-json -p deno_npm -p deno_npm_cache --all-targets -- -D warnings
  • cargo test -p specs_tests --test specs -- specs::install::minimum_dependency_age_slim_packument
  • cargo test -p specs_tests --test specs -- specs::npm::trust_signals
  • cargo fmt --check
  • cargo bench -p fast-registry-json --bench benchmark --no-run

@nathanwhit
nathanwhit marked this pull request as ready for review July 2, 2026 06:07
@nathanwhit nathanwhit changed the title perf(npm): lazily parse npm packuments perf(npm): index packuments with SIMD JSON scanner Jul 2, 2026

@bartlomieju bartlomieju left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Strong PR — the lazy-index design is sound and the correctness-critical invariants hold up under inspection. I traced the slim-cache field coverage, the trust-evidence round-trip, time handling, and the indexed-vs-fallback trust path, and found no blocking bugs. My comments are about robustness/documentation of two load-bearing tricks plus CI coverage; overall this is approve-pending-green-CI.

Things I checked that are fine: cross-block strings / escaped quotes / split UTF-8 / unclosed input are covered by tests + fuzz + a serde_json differential over 64 generated packuments; the slim dist allowlist matches NpmPackageVersionDistInfo exactly; exports is skip_deserializing and the LSP fetches the full packument separately (cli/npm.rs), so dropping it from the slim cache is safe; the indexed trust fast-path in common.rs is semantically identical to the fallback loop; and the lazy variant's mutating methods aren't on the resolution hot path.

Inline comments cover: (1) x86_64/Windows CI coverage, (2) the slim allowlist as a silent-data-loss trap for future fields, (3) the synthetic provenance:true, (4) the etag byte-scan invariant.

Nits (non-blocking): once_cell::sync::OnceCell could be std::sync::OnceLock; keys()/values()/iter() return Box<dyn Iterator> (a heap alloc per call on a per-package path — an enum iterator would avoid it); dev-dependency cycle deno_npm <-> fast-registry-json; confirm the incidental windows-sys/libloading bumps in Cargo.lock are pulled by the new deps and not a stray cargo update; pluck_versions is now only used by benches/fuzz.

Comment thread libs/fast-registry-json/src/lib.rs
Comment thread libs/npm_cache/lib.rs
output.push(b'{');
let mut first = true;
let mut wrote_version = false;
for key in [

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This hardcoded allowlist is now the only thing that determines which version fields survive into the persisted cache. I verified it currently covers every field NpmPackageVersionInfo deserializes except scripts (intentional -> hasInstallScript) and exports (skip_deserializing, LSP-only). The risk is the next field added to NpmPackageVersionInfo: it'll be silently dropped from cache with no compile error, and the stale data is sticky. This is strictly worse than the old slim_full_packument, which kept everything-except-scripts. Suggest a round-trip test (fully-populated NpmPackageVersionInfo -> slim_package_info_bytes -> from_packument_slice, asserting field-for-field equality) plus a comment on the struct pointing back here.

Comment thread libs/npm_cache/lib.rs
Comment thread libs/npm_cache/lib.rs Outdated
@nathanwhit
nathanwhit force-pushed the codex/lazy-npm-packuments branch 5 times, most recently from 8671d49 to 2f7087c Compare July 2, 2026 20:33
@nathanwhit
nathanwhit force-pushed the codex/lazy-npm-packuments branch from 2f7087c to 5aff3a3 Compare July 2, 2026 21:11
@nathanwhit
nathanwhit force-pushed the codex/lazy-npm-packuments branch from a54c4ed to 9f67594 Compare July 2, 2026 22:36

@bartlomieju bartlomieju left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚢 it

@nathanwhit
nathanwhit merged commit 9ae8fbd into denoland:main Jul 3, 2026
268 of 270 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants