perf(config): literal-suffix reject and prune irrelevant globs per base#35688
Conversation
Two follow-ups to #35039 on the fmt/lint/test file-discovery hot path. GlobPattern now precomputes the trailing glob-free ASCII run of its pattern (e.g. ".ts" for "**/*.ts"). matches_path() rejects any path that doesn't end with that suffix before running the glob engine, using an ASCII case-insensitive compare that matches the engine's own case-insensitive options. The run stops at any glob metacharacter (including the escaped brackets "[[]"/"[]]") and at any non-ASCII byte, so the suffix stays a genuine necessary condition for a match. split_by_base() no longer hands a base every include pattern inherited from its ancestors; can_match_under() drops patterns that can provably never match anything under that base (e.g. "src/a*/**/*.ts" is not tested against files walked under "src/b"). The check is segment-wise and errs toward keeping a pattern whenever a match is still possible, so it only removes dead weight and never changes which files are collected. This resolves the long-standing todo() in that loop.
The literal_suffix is computed from the pattern text normalized to '/', but matches_path receives OS paths which on Windows use '\' as the separator. A suffix that spans a directory boundary (e.g. '/deno.json') therefore never matched a Windows path ending in '\deno.json', so every workspace member config file was rejected by the fast-path and workspace globbing broke on Windows only. Treat '\' and '/' as equivalent in path_ends_with_ascii_ci. Normalizing only widens what passes the pre-filter (the glob engine still runs afterward), so it never drops a genuine match.
split_by_base() collapses an empty applicable_includes to `include: None`, which means "include everything under this base". That was safe before per-base pruning because the set was only empty when no include pattern was anchored at or above the base (a broad path include covered it). can_match_under() introduced a second way to empty the set: a base can inherit candidate patterns that are all pruned as unable to match under it. Collapsing that to None flips a closed include set into include-everything and collects files that were never in the include set (e.g. a negated exclude base sitting outside every glob include's reach). Track whether any candidate pattern existed before pruning and, when pruning empties a non-empty candidate set, keep a closed (empty) include set instead of None so nothing under the base is collected. Add a regression test.
Benchmark: file-discovery / glob-matching hot pathMeasured the effect of this PR (literal-suffix reject + tl;dr — on a deliberately matching-heavy tree, wall-clock is ~1.1x Environment
SetupSynthetic tree chosen to make the changed code a meaningful share of runtime:
The collector calls Results —
|
| round | system 2.9.1 (ms) | branch (ms) | wall speedup |
|---|---|---|---|
| 1 | 319.8 ± 35.0 | 272.5 ± 3.1 | 1.17x |
| 2 | 320.8 ± 47.5 | 278.4 ± 15.7 | 1.15x |
| 3 | 305.6 ± 6.1 | 286.3 ± 38.1 | 1.07x |
User CPU: system ~130 ms → branch ~100 ms (~23% less). (System/kernel time
~410-540 ms, essentially unchanged.)
Results — deno lint (12 runs x 3 rounds)
| round | system 2.9.1 (ms) | branch (ms) | wall speedup |
|---|---|---|---|
| 1 | 305.2 ± 3.4 | 272.7 ± 3.7 | 1.12x |
| 2 | 303.8 ± 3.8 | 272.1 ± 4.3 | 1.12x |
| 3 | 308.3 ± 13.0 | 275.8 ± 12.6 | 1.12x |
User CPU: system ~115 ms → branch ~84 ms (~27% less). The lint rounds were
very stable (1.12x every round).
Interpretation
- The workload is syscall/IO-bound (directory traversal), and that portion is
identical on both binaries. So wall-clock (~1.1x) understates the change;
the honest per-CPU signal is the ~23-27% reduction in user time, which is
exactly the code path this PR touches. - Real repositories have far fewer files and patterns than this stress tree, so
they will see smaller absolute wall-clock gains, but the direction (less CPU
spent in glob matching, no extra allocations) holds.
Caveats
- Synthetic microbenchmark on a single machine; a few
fmtruns were flagged
as statistical outliers (background noise) — the tighterlintnumbers are
the more trustworthy read. Both agree. - Correctness note unrelated to timing: the per-base pruning could previously
flip a base's closed include set toinclude: None(include-everything) when
every inherited pattern was pruned; fixed on this branch with a regression
test (file_patterns_split_by_base_dir_pruned_to_empty).
Harness (local, not committed): a small generator builds the tree +
deno.json, and hyperfine compares the two binaries running fmt --check /
lint inside it.
Two follow-ups to #35039, both on the file-discovery hot path shared by
fmt,lint, andtest. #35039 already precomputes a pattern's literalbase prefix and matches against the shorter relative path; this builds on
that with the two remaining ideas from #21830.
The first is a literal-suffix reject.
GlobPatternnow precomputes themaximal trailing glob-free run of its pattern (for example
.tsfor**/*.ts). Any path matched by the pattern must end with that run, somatches_path()checks it and bails before touching the glob engine whenit doesn't. The comparison is ASCII case-insensitive to match the glob
engine's own
case_sensitive: falseoption. To keep the suffix a genuinenecessary condition, the run stops at any glob metacharacter, including
the escaped brackets
[[]and[]]that literal brackets are rewrittento, and at any non-ASCII byte. This is the optimization suggested in the
issue comment (globs ending in a static string can check that part
first). Since the suffix is computed from the pattern text normalized to
/whilematches_path()receives OS paths, the comparison treats\and
/as equivalent; otherwise a suffix spanning a directory boundary(such as
/deno.json) would reject every workspace member config file onWindows. Normalizing only widens what passes the pre-filter (the glob
engine still runs afterward), so it never drops a genuine match.
The second prunes irrelevant patterns per base. When
split_by_base()builds the include set for a base directory it walks the base's ancestors
and inherits their patterns. A new
can_match_under()check now dropsinherited patterns that can provably never match anything under that
base, so for includes like
src/a*/**/*.tsandsrc/b/**/*.tsthe walkof
src/bno longer tests every file against thea*pattern. The checkis segment-wise, understands
*,?, and**, and errs toward keeping apattern whenever a match is still possible, so it only removes dead weight
and never changes which files are collected. This resolves the
long-standing
todo()in that loop.Pruning exposed an edge in
split_by_base(): it collapses an emptyapplicable-include set to
include: None, which means include-everythingunder the base. That was safe when the set could only be empty because no
include pattern was anchored at or above the base, but pruning added a
second way to empty it: a base can inherit candidate patterns that are
all dropped as unable to match under it. Collapsing that case to
Noneflipped a closed include set into include-everything and collected files
that were never included (for example a negated exclude base sitting
outside every glob include's reach).
split_by_base()now tracks whetherany candidate pattern existed before pruning and keeps a closed (empty)
include set when pruning removed them all.
Added unit tests covering suffix extraction (case folding, escaped
brackets, and non-ASCII boundaries), suffix-based rejection including
negated patterns and backslash-separated paths,
can_match_under, and aregression test for the pruned-to-empty include set.
Refs #21830.