Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/perf-ignored-reduceplan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"watchpack": patch
---

perf: skip the path-separator replacement when the input has no backslash
(benchmarks measure ~35–45% less time for `ignored` matchers on POSIX paths),
fast-path single-element `ignored` arrays, and make `reducePlan`'s selection
loop walk only structurally valid candidates with an early exit when the
ideal reduction is found (measured ~20–40% faster on medium and large
plans). Adds a tinybench suite under `bench/` and a CodSpeed GitHub Actions
workflow so future regressions are caught automatically.
40 changes: 40 additions & 0 deletions .github/workflows/benchmarks.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
name: Benchmarks

on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

permissions:
contents: read
id-token: write # Required for OIDC authentication with CodSpeed

jobs:
benchmark:
runs-on: ubuntu-latest
permissions:
pull-requests: write
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0

- name: Use Node.js
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version: lts/*
cache: npm

- run: npm ci

- name: Run benchmarks
uses: CodSpeedHQ/action@fa0c9b1770f933c1bc025c83a9b42946b102f4e6 # v4.10.4
with:
run: npm run benchmark
mode: "simulation"
71 changes: 71 additions & 0 deletions benchmark/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# Benchmarks

This directory holds watchpack's performance benchmarks. The layout mirrors
[webpack/enhanced-resolve's `benchmark/`](https://github.com/webpack/enhanced-resolve/tree/main/benchmark):
each scenario lives under `cases/<name>/index.bench.mjs`, `run.mjs` discovers
every case and runs it through [tinybench](https://github.com/tinylibs/tinybench),
and the same entry point works both locally (wall-clock) and under
[CodSpeed](https://codspeed.io/) (instruction-count simulation) via the
`with-codspeed.mjs` bridge.

## Running

```sh
# Run every case
npm run benchmark

# Run only cases whose directory name contains "ignored"
BENCH_FILTER=ignored npm run benchmark
# - or, equivalently -
npm run benchmark -- ignored
```

The output is a table of `ops/s`, mean latency, p99 latency, RME, and
sample count per registered task. When run under `CodSpeedHQ/action` the
bridge switches to instrumentation mode automatically.

## Layout

```
bench/
├── README.md
├── run.mjs # entry: discovers cases, runs them, prints a table
├── with-codspeed.mjs # tinybench <-> @codspeed/core bridge
└── cases/
└── <case-name>/
└── index.bench.mjs
```

Each `index.bench.mjs` exports a default `register(bench, ctx)` function
that calls `bench.add(name, fn)` one or more times. The `ctx` argument is
`{ caseName, caseDir, fixtureDir }`; `fixtureDir` points at
`cases/<name>/fixture/` (the directory is optional — only create it if you
need a real on-disk tree).

## Writing a case

1. Create `bench/cases/<name>/index.bench.mjs`.
2. Default-export a `register` function.
3. Pre-build expensive fixtures **outside** the benchmark callback so only
the hot path is measured.
4. Each `bench.add` body should loop over a fixed batch of inputs so the
measurement window sees enough work to be stable (tens of microseconds
minimum).
5. Avoid any non-determinism — use fixed request lists, no `Math.random`.
6. Focus one case on one scenario. If you need a materially different
shape (warm vs. cold cache, POSIX vs. Windows paths), prefer a new case
directory over piling `bench.add` calls into an existing one.

## CodSpeed

`bench/run.mjs` wraps the `Bench` with `withCodSpeed()`. When `CODSPEED_*`
env vars are absent the wrapper returns the bench untouched and tinybench's
built-in timing is used. Under `CodSpeedHQ/action` with
`mode: "simulation"` the wrapper overrides `bench.run` to call the
instrumentation hooks once per task, which produces reproducible
instruction-count measurements independent of the runner's load.

The `@codspeed/tinybench-plugin` package is intentionally not used here:
it reads private tinybench v6 Task fields and breaks in simulation mode.
Both webpack and enhanced-resolve ran into the same issue and wrote their
own bridges; `with-codspeed.mjs` is ported from enhanced-resolve's.
87 changes: 87 additions & 0 deletions benchmark/cases/ignored-cross-platform/index.bench.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
/*
* ignored-cross-platform
*
* Stress-tests the separator-normalization path in the `ignored` matcher.
* The matcher must handle native Windows paths (with backslashes), paths
* that have already been normalized to POSIX, and mixed bags produced by
* cross-platform tools. This case isolates those scenarios from the plain
* POSIX batch measured by `ignored-match` so the backslash-heavy code path
* has its own CodSpeed trend line.
*/

import Watchpack from "../../../lib/index.js";

const UNIX_PATHS = [
"/home/user/project/src/index.js",
"/home/user/project/src/components/App.jsx",
"/home/user/project/node_modules/react/index.js",
"/home/user/project/dist/bundle.js",
"/home/user/project/.git/HEAD",
"/home/user/project/coverage/lcov.info",
"/home/user/project/src/utils/helpers.ts",
"/home/user/project/test/fixtures/a.js",
"/home/user/project/README.md",
"/home/user/project/package.json",
];

const WINDOWS_PATHS = UNIX_PATHS.map((path) => path.replace(/\//g, "\\"));
const MIXED_PATHS = UNIX_PATHS.map((path, i) =>
i % 2 === 0 ? path : path.replace(/\//g, "\\"),
);

// Simulates a monorepo deep-path batch: each path has ~17 segments so the
// regex has to scan a long string before committing to match/no-match.
const DEEP_PATHS = Array.from({ length: 10 }, (_, i) => {
const segments = Array.from({ length: 15 }, (_, j) => `level${j}`);
segments.push(i === 3 ? "node_modules" : `leaf${i}`);
segments.push("index.js");
return `/${segments.join("/")}`;
});

/**
* @param {import("../../../lib/index").WatchOptions} options options
* @returns {(item: string) => boolean} true when ignored, otherwise false
*/
const buildIgnored = (options) => new Watchpack(options).watcherOptions.ignored;

const LARGE_ARRAY_IGNORED = [
"**/node_modules",
"**/.git",
"**/dist",
"**/build",
"**/coverage",
"**/.cache",
"**/.next",
"**/.nuxt",
"**/tmp",
"**/*.log",
];

/**
* @param {import("tinybench").Bench} bench bench
*/
export default function register(bench) {
const regexpMatcher = buildIgnored({
ignored: /node_modules|\.git|dist|coverage/,
});
const arrayMatcher = buildIgnored({ ignored: LARGE_ARRAY_IGNORED });

bench.add("ignored-cross-platform: regex against windows paths", () => {
for (const path of WINDOWS_PATHS) regexpMatcher(path);
});
bench.add("ignored-cross-platform: regex against mixed separators", () => {
for (const path of MIXED_PATHS) regexpMatcher(path);
});
bench.add("ignored-cross-platform: regex against deep posix paths", () => {
for (const path of DEEP_PATHS) regexpMatcher(path);
});
bench.add("ignored-cross-platform: array[10] against windows paths", () => {
for (const path of WINDOWS_PATHS) arrayMatcher(path);
});
bench.add(
"ignored-cross-platform: array[10] against deep posix paths",
() => {
for (const path of DEEP_PATHS) arrayMatcher(path);
},
);
}
86 changes: 86 additions & 0 deletions benchmark/cases/ignored-match/index.bench.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
/*
* ignored-match
*
* Exercises the `ignored` matcher against a realistic POSIX path batch for
* every supported option shape (regex, glob string, short/long array, plain
* predicate, and the "no ignored option" no-op fast path). The matcher is
* what webpack hits per file for every watched entry, so the shape of this
* batch dominates per-rebuild time in large projects.
*/

import Watchpack from "../../../lib/index.js";

const UNIX_PATHS = [
"/home/user/project/src/index.js",
"/home/user/project/src/components/App.jsx",
"/home/user/project/node_modules/react/index.js",
"/home/user/project/dist/bundle.js",
"/home/user/project/.git/HEAD",
"/home/user/project/coverage/lcov.info",
"/home/user/project/src/utils/helpers.ts",
"/home/user/project/test/fixtures/a.js",
"/home/user/project/README.md",
"/home/user/project/package.json",
];

/**
* Reach into a Watchpack instance to get at the normalized matcher without
* duplicating the option-compilation logic.
* @param {import("../../../lib/index").WatchOptions} options options
* @returns {(item: string) => boolean} true when ignored, otherwise false
*/
const buildIgnored = (options) => new Watchpack(options).watcherOptions.ignored;

/**
* @param {import("tinybench").Bench} bench bench
*/
export default function register(bench) {
const noneMatcher = buildIgnored({});
const regexpMatcher = buildIgnored({
ignored: /node_modules|\.git|dist|coverage/,
});
const stringMatcher = buildIgnored({ ignored: "**/node_modules" });
const singletonArrayMatcher = buildIgnored({ ignored: ["**/node_modules"] });
const smallArrayMatcher = buildIgnored({
ignored: ["**/node_modules", "**/.git"],
});
const largeArrayMatcher = buildIgnored({
ignored: [
"**/node_modules",
"**/.git",
"**/dist",
"**/build",
"**/coverage",
"**/.cache",
"**/.next",
"**/.nuxt",
"**/tmp",
"**/*.log",
],
});
const functionMatcher = buildIgnored({
ignored: (path) => path.includes("node_modules") || path.includes(".git"),
});

bench.add("ignored-match: no ignored option (noop fast path)", () => {
for (const path of UNIX_PATHS) noneMatcher(path);
});
bench.add("ignored-match: regex matcher", () => {
for (const path of UNIX_PATHS) regexpMatcher(path);
});
bench.add("ignored-match: glob string matcher", () => {
for (const path of UNIX_PATHS) stringMatcher(path);
});
bench.add("ignored-match: array[1] matcher", () => {
for (const path of UNIX_PATHS) singletonArrayMatcher(path);
});
bench.add("ignored-match: array[2] matcher", () => {
for (const path of UNIX_PATHS) smallArrayMatcher(path);
});
bench.add("ignored-match: array[10] matcher", () => {
for (const path of UNIX_PATHS) largeArrayMatcher(path);
});
bench.add("ignored-match: function matcher", () => {
for (const path of UNIX_PATHS) functionMatcher(path);
});
}
70 changes: 70 additions & 0 deletions benchmark/cases/link-resolver/index.bench.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/*
* link-resolver
*
* LinkResolver resolves directory + file paths while expanding any symlinks
* along the way. Webpack hits it on every watched entry before asking the
* platform's fs.watch, so both the cold (first seen) and warm (cache hit)
* paths matter: the former bounds worst-case rebuild latency, the latter
* bounds the per-file cost of the steady state.
*
* To keep the benchmark deterministic we aim the resolver at a path that
* does not exist. readlinkSync throws ENOENT, LinkResolver catches it
* silently and walks its cache / parent chain exactly like it would for a
* real path — without any filesystem side effects that would make CodSpeed
* instrumentation noisy.
*/

import LinkResolver from "../../../lib/LinkResolver.js";

const SEP = process.platform === "win32" ? "\\" : "/";
const ROOT =
process.platform === "win32" ? "C:\\nonexistent_bench" : "/nonexistent_bench";

const makePath = (depth) => {
let path = ROOT;
for (let i = 0; i < depth; i++) path += `${SEP}level${i}`;
return path;
};

const shallowPaths = Array.from(
{ length: 100 },
(_, i) => `${ROOT}${SEP}file${i}`,
);
const mediumPaths = Array.from(
{ length: 100 },
(_, i) => `${makePath(5)}${SEP}file${i}`,
);
const deepPaths = Array.from(
{ length: 50 },
(_, i) => `${makePath(15)}${SEP}file${i}`,
);

/**
* @param {import("tinybench").Bench} bench bench
*/
export default function register(bench) {
// Pre-populated resolver for the warm/cache-hit measurements.
const warmResolver = new LinkResolver();
for (const path of shallowPaths) warmResolver.resolve(path);
for (const path of mediumPaths) warmResolver.resolve(path);
for (const path of deepPaths) warmResolver.resolve(path);

bench.add("link-resolver: cold shallow batch (depth=1, n=100)", () => {
const resolver = new LinkResolver();
for (const path of shallowPaths) resolver.resolve(path);
});
bench.add("link-resolver: cold medium batch (depth=5, n=100)", () => {
const resolver = new LinkResolver();
for (const path of mediumPaths) resolver.resolve(path);
});
bench.add("link-resolver: cold deep batch (depth=15, n=50)", () => {
const resolver = new LinkResolver();
for (const path of deepPaths) resolver.resolve(path);
});
bench.add("link-resolver: warm shallow batch (cache hit, n=100)", () => {
for (const path of shallowPaths) warmResolver.resolve(path);
});
bench.add("link-resolver: warm deep batch (cache hit, n=50)", () => {
for (const path of deepPaths) warmResolver.resolve(path);
});
}
Loading
Loading