Skip to content

perf: reduce resolution syscalls by unifying stat and lstat#1182

Merged
graphite-app[bot] merged 1 commit into
mainfrom
perf/lstat-first-metadata
May 29, 2026
Merged

perf: reduce resolution syscalls by unifying stat and lstat#1182
graphite-app[bot] merged 1 commit into
mainfrom
perf/lstat-first-metadata

Conversation

@Boshen

@Boshen Boshen commented May 29, 2026

Copy link
Copy Markdown
Member

Summary

Module resolution looked at each path twice: a stat (metadata, follows symlinks) for is_file/is_dir during resolution, then an lstat (symlink_metadata) on the same path again during canonicalization. Most paths aren't symlinks, so the two passes were redundant — for npm-flat, 49 lstats found only 2 actual symlinks, and ~37 of those paths had already been stat'd.

This unifies them so is_file/is_dir are lstat-first.

What changed

  • CachedMeta now holds two 1-byte slots — link (the cached lstat view) and followed (the cached stat view).
  • is_file/is_dir read the cached lstat; for the common non-symlink case that is the answer (no stat), and only a genuine symlink triggers a follow-up stat.
  • Canonicalization reuses the same cached lstat instead of issuing its own.
  • FileSystemOs::symlink_metadata is now VPath-aware for Yarn PnP. Previously only metadata() translated virtual/zip paths, so lstat-backed is_file/is_dir would have failed under PnP.

Net per path: savings = |stat∩lstat| − symlinks-followed.

Results — 18–28% fewer syscalls

Measured on the 16-request package-manager workload (same one the package_managers bench uses):

layout before after
npm-flat / yarn-flat / bun-flat 126 91
pnpm-isolated / hoisted / bun-isolated 149 122
yarn-isolated 142 115
yarn-pnp 144 106

stat calls collapse from ~40–51 down to 0–8 (only the genuine symlink-follows). Behavior of is_file/is_dir is unchanged — they still return symlink-followed results.

🤖 Generated with Claude Code

@codecov

codecov Bot commented May 29, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 93.46%. Comparing base (a539c40) to head (4e9ac5e).
⚠️ Report is 1 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1182      +/-   ##
==========================================
+ Coverage   93.33%   93.46%   +0.13%     
==========================================
  Files          22       22              
  Lines        4184     4208      +24     
==========================================
+ Hits         3905     3933      +28     
+ Misses        279      275       -4     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@codspeed-hq

codspeed-hq Bot commented May 29, 2026

Copy link
Copy Markdown

Merging this PR will degrade performance by 5.07%

⚠️ Different runtime environments detected

Some benchmarks with significant performance changes were compared across different runtime environments,
which may affect the accuracy of the results.

Open the report in CodSpeed to investigate

⚡ 1 improved benchmark
❌ 4 regressed benchmarks
✅ 16 untouched benchmarks
⏩ 5 skipped benchmarks1

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Benchmark BASE HEAD Efficiency
pm/bun-flat 925.2 µs 996.8 µs -7.19%
pm/npm-flat 936.8 µs 1,027.9 µs -8.87%
pm/yarn-flat 889.3 µs 978.8 µs -9.14%
resolver_memory[multi-thread] 432.9 µs 413.4 µs +4.72%
resolver_real[multi-thread] 401 µs 418.7 µs -4.21%

Tip

Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.


Comparing perf/lstat-first-metadata (4e9ac5e) with main (a539c40)2

Open in CodSpeed

Footnotes

  1. 5 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

  2. No successful run was found on main (4e9ac5e) during the generation of this report, so a539c40 was used instead as the comparison base. There might be some changes unrelated to this pull request in this report.

@Boshen Boshen force-pushed the perf/lstat-first-metadata branch from 44d7fcf to e86ec37 Compare May 29, 2026 12:02

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e86ec378f3

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/cache/cached_meta.rs Outdated
graphite-app Bot pushed a commit that referenced this pull request May 29, 2026
## Summary

`FileSystemOs::metadata` already translates Yarn PnP **virtual** and **zip** paths through `VPath` before hitting the filesystem, but `symlink_metadata` did not — it `lstat`'d the raw path directly:

```rust
fn symlink_metadata(&self, path: &Path) -> io::Result<FileMetadata> {
    Self::symlink_metadata(path)   // no VPath translation
}
```

For a virtual/zip path that has no physical existence, that `lstat` fails, so canonicalization (`canonicalize_with_visited`) sees the error, treats the path as a non-symlink, and cannot resolve symlinks correctly under PnP.

This makes `symlink_metadata` mirror `metadata`'s `VPath` handling. Zip entries are never symlinks, so they reuse the same `file_type` lookup as `metadata`; virtual/native paths fall through to the real `lstat` on the translated physical path.

This is also a prerequisite for #1182, which routes `is_file`/`is_dir` through the cached `lstat` and so requires `symlink_metadata` to be VPath-aware.

## Verification

- `cargo test --all-features` (includes the Yarn PnP suite) — green
- `fixtures/pnp` JS resolver test — green

🤖 Generated with [Claude Code](https://claude.com/claude-code)
@Boshen Boshen force-pushed the perf/lstat-first-metadata branch 2 times, most recently from c3748ff to 2284d67 Compare May 29, 2026 14:20
@Boshen Boshen added the merge label May 29, 2026

Boshen commented May 29, 2026

Copy link
Copy Markdown
Member Author

Merge activity

## Summary

Module resolution looked at each path twice: a `stat` (`metadata`, follows symlinks) for `is_file`/`is_dir` during resolution, then an `lstat` (`symlink_metadata`) on the same path again during canonicalization. Most paths aren't symlinks, so the two passes were redundant — for `npm-flat`, 49 lstats found only **2** actual symlinks, and ~37 of those paths had already been `stat`'d.

This unifies them so `is_file`/`is_dir` are **lstat-first**.

## What changed

- `CachedMeta` now holds two 1-byte slots — `link` (the cached `lstat` view) and `followed` (the cached `stat` view).
- `is_file`/`is_dir` read the cached `lstat`; for the common non-symlink case that *is* the answer (no `stat`), and only a genuine symlink triggers a follow-up `stat`.
- Canonicalization reuses the same cached `lstat` instead of issuing its own.
- `FileSystemOs::symlink_metadata` is now VPath-aware for Yarn PnP. Previously only `metadata()` translated virtual/zip paths, so lstat-backed `is_file`/`is_dir` would have failed under PnP.

Net per path: `savings = |stat∩lstat| − symlinks-followed`.

## Results — 18–28% fewer syscalls

Measured on the 16-request package-manager workload (same one the `package_managers` bench uses):

| layout | before | after |
|---|---|---|
| npm-flat / yarn-flat / bun-flat | 126 | **91** |
| pnpm-isolated / hoisted / bun-isolated | 149 | **122** |
| yarn-isolated | 142 | **115** |
| yarn-pnp | 144 | **106** |

`stat` calls collapse from ~40–51 down to 0–8 (only the genuine symlink-follows). Behavior of `is_file`/`is_dir` is unchanged — they still return symlink-followed results.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
@graphite-app graphite-app Bot force-pushed the perf/lstat-first-metadata branch from 2284d67 to 4e9ac5e Compare May 29, 2026 14:41
@graphite-app graphite-app Bot merged commit 4e9ac5e into main May 29, 2026
18 checks passed
@graphite-app graphite-app Bot removed the merge label May 29, 2026
@graphite-app graphite-app Bot deleted the perf/lstat-first-metadata branch May 29, 2026 14:44
@oxc-guard oxc-guard Bot mentioned this pull request May 29, 2026
Boshen pushed a commit that referenced this pull request Jun 3, 2026
## 🤖 New release

* `oxc_resolver`: 11.20.0 -> 11.21.0
* `oxc_resolver_napi`: 11.20.0 -> 11.21.0

<details><summary><i><b>Changelog</b></i></summary><p>

## `oxc_resolver`

<blockquote>

##
[11.21.0](v11.20.0...v11.21.0)
- 2026-06-03

### <!-- 0 -->🚀 Features

- *(tsconfig)* support package.json imports field in extends
([#1199](#1199)) (by
@Boshen)

### <!-- 1 -->🐛 Bug Fixes

- *(tsconfig)* apply each referenced project's own `allowJs`
([#1198](#1198)) (by
@shulaoda)
- make symlink_metadata VPath-aware for Yarn PnP
([#1183](#1183)) (by
@Boshen)

### <!-- 4 -->⚡ Performance

- borrow relative main field instead of allocating a "./" prefix
([#1187](#1187)) (by
@Boshen)
- *(cache)* move package.json path into parse instead of cloning
([#1186](#1186)) (by
@Boshen)
- eliminate symlink stat syscalls by reusing canonicalization
([#1184](#1184)) (by
@Boshen)
- reduce resolution syscalls by unifying stat and lstat
([#1182](#1182)) (by
@Boshen)

### <!-- 9 -->💼 Other

- add baselines for each package manager x node_modules layout
([#1176](#1176)) (by
@Boshen)

### Contributors

* @shulaoda
* @Boshen
* @renovate[bot]
</blockquote>



</p></details>

---
This PR was generated with
[release-plz](https://github.com/release-plz/release-plz/).

Co-authored-by: oxc-guard[bot] <276638029+oxc-guard[bot]@users.noreply.github.com>
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.

1 participant