Repository navigation
Optimize FileMatcher wildcard enumeration - #14663
ViktorHofer merged 7 commits into
Conversation
454387e to
9c560e3
Compare
There was a problem hiding this comment.
Pull request overview
This PR introduces a new optimized wildcard expansion/enumeration path behind FileMatcher.GetFiles, gated by ChangeWave 18.11 (Auto selects optimized unless disabled via MSBUILDDISABLEFEATURESFROMVERSION=18.11). The implementation keeps the legacy path available as a compatibility fallback and adds a new state-set glob matcher adapted from Touki.
Changes:
- Add
FileMatcherImplementationselection (Auto/Legacy/Optimized), direct-enumerator traversal for capable file systems, and cache-key partitioning by implementation. - Introduce
MSBuildPathMatcher(specialized fast paths + bounded NFA state-set) and wire it into optimized enumeration logic. - Add benchmarks and extensive unit tests validating optimized vs legacy behavior, plus ChangeWaves documentation update.
Reviewed changes
Copilot reviewed 11 out of 11 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| src/MSBuild.Benchmarks/MSBuildPathMatcherBenchmark.cs | Adds matcher-only microbenchmarks comparing legacy regex matching vs the new matcher. |
| src/MSBuild.Benchmarks/FileMatcherBenchmark.cs | Adds filesystem traversal benchmarks (cold/warm/cache-backed) comparing legacy vs optimized enumeration. |
| src/Framework/Utilities/FileMatcher.cs | Implements optimized enumeration path, direct traversal option, implementation selection & cache key partitioning. |
| src/Framework/Globbing/MSBuildPathMatcher.cs | New optimized path+filename matcher used by the optimized enumeration path. |
| src/Framework/FileSystem/MSBuildOnWindowsFileSystem.cs | Marks Windows filesystem as supporting direct enumeration. |
| src/Framework/FileSystem/ManagedFileSystem.cs | Marks managed filesystem as supporting direct enumeration. |
| src/Framework/FileSystem/IFileSystem.cs | Adds IDirectFileSystemEnumeration capability interface. |
| src/Framework/FileSystem/CachingFileSystemWrapper.cs | Propagates direct-enumeration capability through the wrapper. |
| src/Framework.UnitTests/Globbing/MSBuildPathMatcher_Tests.cs | Adds unit tests for directory/file matching edge cases and platform semantics. |
| src/Framework.UnitTests/Globbing/FileMatcherOptimized_Tests.cs | Adds differential tests validating optimized vs legacy across patterns, excludes, caching, injected file systems, and error paths. |
| documentation/wiki/ChangeWaves.md | Documents the new ChangeWave 18.11 feature entry. |
💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.
9c560e3 to
1778859
Compare
Expert MSBuild Review — Optimized FileMatcher (
|
| Severity | Count | Notes |
|---|---|---|
| 🔴 Critical | 2 | silent item-set loss; measured perf regression |
| 🟠 High | 8 | 4 more confirmed divergences + alloc/caching/API traps |
| 🟡 Medium | 10 | design doc, diagnostics, duplication, test gaps |
| ⚪ Low/Nit | 12 | naming, style, docs |
Clean dimensions (6/24): #1 Backwards Compatibility (the gate itself is correct — see note), #9 Target Authoring (N/A, no .targets/.props), #13 Concurrency (only a NIT), #19 Build Infrastructure (full build.cmd green, 0 warnings, all 3 Framework TFMs), #23 Dependency Management (no package/version edits; Microsoft.IO.Redist 6.1.0 already exposes every API used), #24 Security (symlink-loop protection preserved per-directory; AttributesToSkip = 0 is deliberate parity with EnumerationOptions.Compatible, not a weakening).
Note on #1: dimension 1 is "clean" only in the mechanical sense —
ResolvedImplementationis the single gate, all production call sites useAuto, the opt-out works, and the exception refactor is provably behavior-preserving. But the wave defaults on, so every Critical/High divergence below is a breaking change for anyone who doesn't setMSBUILDDISABLEFEATURESFROMVERSION=18.11.
🔴 Critical
C1 — Excludes are not rebased onto the include's base directory: **/obj/** silently deletes the entire result set
src/Framework/Utilities/FileMatcher.cs:2746-2752 (AddExclude), :2666-2674 (callback driver), :2985-2992 (direct driver) — vs legacy :3320-3333
Reproduced (passing xUnit repro, both drivers):
// files: <root>/obj/gen/g1.cs, <root>/obj/gen/deeper/g2.cs
matcher.GetFiles(root, @"obj\gen\**\*.cs", [@"**\obj\**"]);
// Legacy: obj\gen\g1.cs, obj\gen\deeper\g2.cs
// Optimized: <empty>Also confirmed for bin\Debug\**\*.cs + **\bin\**, src/obj/**/*.cs + **/obj/**, node_modules/pkg/**/*.cs + **/node_modules/**, and **/o?j/**.
Why it matters: legacy handles the **/{name}/** exclude shape via FilesSearchData.DirectoryPattern + IsInsideMatchingDirectory, rebasing the exclude onto the include's base and keeping IsInsideMatchingDirectory == false — so the obj that already sits in the include's fixed directory part is never re-examined and the exclude never fires. AddExclude instead keeps the exclude's own BaseDirectory and matches the full relative path, so the prefix obj matches and everything is dropped.
This is the single most common shape in the .NET SDK: DefaultItemExcludes is **/bin/**;**/obj/**, and source generators / CompilerGeneratedFilesOutputPath / T4 / protobuf outputs are routinely included from under obj/. Generated .cs files silently vanish from @(Compile) — no error, no warning, just a build that fails later with CS0246.
Fix: reproduce legacy's rebasing for the DirectoryPattern shape. When the exclude's base is an ancestor of the include's base, restart the exclude matcher at the include base in the "not yet inside a matching directory" state, so only directories discovered below the include base can satisfy the {pattern} segment:
// in AddExclude, when excludeState.SearchData.DirectoryPattern is not null
// and excludeState.BaseDirectory is an ancestor of state.BaseDirectory:
// rebase to state.BaseDirectory and require the pattern segment to be
// matched by a directory discovered during traversal, not by the fixed prefix.Add OptimizedMatchesLegacy inline data for include-rooted-under-an-excluded-directory.
C2 — The "optimization" is a 2.2×–3.0× wall-clock regression on multi-core for the evaluation-time shape
src/Framework/Utilities/FileMatcher.cs:2649-2745 (EnumerateDirectory), :2626-2630 (direct driver loop) — vs legacy :1024-1070, :3384-3389
Legacy fans out one level deep with Parallel.ForEach (dop = max(1, cores/2), active when cores >= 4). Both optimized drivers are strictly single-threaded — no Parallel, Task, or Thread anywhere in the new code.
Independently measured (8 logical cores, Release net11.0, warm OS cache, median of 7 alternating runs; opt/legacy ratio, >1 = slower):
| Scenario | wall | cpu | alloc |
|---|---|---|---|
src **/*.cs, 10 SDK excludes, entry-cache (= evaluation path) |
2.18–2.40× | 0.8–1.3× | 1.02× |
| ⤷ same, pinned to 1 core | 0.97–1.15× | 1.00× | 1.02× |
synthetic 100 dirs × (2 .cs + 200 .txt) |
2.74–3.01× | 0.75× | 1.37× |
| ⤷ same, pinned to 1 core | 1.04–1.34× | 1.00× | 1.37× |
artifacts\bin **/*.dll (16 531 files) |
1.09–1.39× | 0.50× | 0.38× |
artifacts **/*.cs, no excludes (773 files) |
0.57–0.90× (faster) | 0.54× | 0.31× |
The single-core control is decisive: every multi-core regression collapses to ≈1.0× when pinned, so the loss is attributable entirely to the missing Parallel.ForEach. CPU time genuinely drops (0.5–0.6× for the direct driver) — the engine is cheaper per unit of work, it just gave up all its concurrency. The regression scales with core count, so a 32-core CI agent will be worse than these 8-core numbers.
Fix: parallelize the top-level subdirectory fan-out of EnumerateDirectory using the same TaskOptions budget legacy uses (collect into a ConcurrentStack<List<string>> as before); for the direct driver, partition over the first level of subdirectories with one FileSystemEnumerator per partition. Do not default wave 18.11 to Optimized until wall-clock parity is demonstrated on a real SDK-shaped tree at ≥8 cores.
🟠 High
H1 — Direct enumerator canonicalizes the root, so lexically-discarded excludes start firing
FileMatcher.cs:2915-2918 (GetFullPath), :3057-3110 — vs legacy lexical comparison :3257-3273
// files under <root>/src/
matcher.GetFiles(root, @".\**\*.cs", [@"src\**"]);
// Legacy: .\src\a.cs, .\src\deeper\b.cs
// Optimized direct: <empty>
// Optimized cached: matches legacy (only the direct driver diverges)Legacy compares exclude/include base directories lexically (IsSubdirectoryOf, Ordinal-IC), so <root>\.\ is neither equal to nor under <root>\src\ and the exclude is discarded outright. Path.GetFullPath on the enumeration root makes them match. Same for x/../x/**/*.cs shapes.
Fix: keep the lexical (un-GetFullPath'd) directory string alongside the resolved one and evaluate excludes against the lexical path, or pre-filter excludes with the same lexical IsSubdirectoryOf test legacy uses before handing them to the enumerator.
H2 — Trailing separator on projectDirectory makes the direct enumerator return absolute paths
FileMatcher.cs:3113-3122 (GetOutputRoot) — vs legacy RemoveProjectDirectory :740-770
matcher.GetFiles(root + Path.DirectorySeparatorChar, @"**\*.cs");
// Legacy: root.cs, src\a.cs
// Optimized: C:\...\<root>\root.cs, C:\...\<root>\src\a.csGetFileSearchData sets BaseDirectory = Normalize(projectDirectory), which trims the trailing separator, so lexicalEnumerationRoot.StartsWith(projectDirectory, Ordinal) fails and stripping is skipped entirely. This changes item Identity/RelativeDir downstream — e.g. Copy's SourceFolders path does Path.Combine(DestinationFolder, srcName, file), which silently discards the destination when file is absolute.
Fix: trim trailing separators from projectDirectory before the StartsWith comparison, mirroring RemoveProjectDirectory. Add a test with a trailing-separator project directory.
H3 — Trailing-dot flag asymmetry between include and exclude matchers
FileMatcher.cs:2770-2772 (useTrailingDotRegex: !matchForExclusion && ...), MSBuildPathMatcher.cs:141-146 — vs legacy identical-spec short-circuit :914-920
// extension-less files README, sub\LICENSE present
matcher.GetFiles(root, @"**\*.", [@"**\*."]);
// Legacy: <empty>
// Optimized direct: README, sub\LICENSEExclude matchers are built with useTrailingDotRegex: false while include matchers use true, so *. matches the extension-less names on the include side but not on the exclude side. Legacy additionally short-circuits when the exclude spec equals the include spec.
Fix: use the same trailing-dot flag for exclude matchers as for include matchers, and/or port legacy's "exclude spec is identical to / a superset of the include spec" bail-out. Note the existing test LogicalTrailingDotExcludeDoesNotRemoveExtensionlessFile currently pins the wrong direction.
H4 — . segments are dropped from the wildcard directory part
MSBuildPathMatcher.cs:672-702 (SplitDirectoryPatterns)
matcher.GetFiles(root, @"*\.\*.cs");
// Legacy: <empty> (walks "." as a literal directory name — FileMatcher.cs:1048)
// Optimized: a\b.csLegacy's Normalize only collapses . in the fixed leading directory. SplitDirectoryPatterns drops . unconditionally, silently rewriting */./*.cs into */*.cs.
Fix: only drop . segments that precede the first wildcard segment; treat a . after a wildcard segment as a literal name. Same review applies to the unconditional consecutive-** collapse (untested — add **/**/*.cs, **/./**/*.cs to the differential corpus).
H5 — Linux: culture-sensitive directory regex ignores the case-sensitivity flag
MSBuildPathMatcher.cs:576-581 (MatchesDirectoryName), :622 (new Regex(..., RegexOptions.IgnoreCase)), :97 — set from FileMatcher.cs:2764, 2780
CreatePathMatcher passes filesystemCaseSensitive: NativeMethods.IsLinux and useCultureSensitiveMatch: RegexFileMatch is not null. Line 97 correctly computes _ignoreCaseDirectoryPatterns[index] == false for pre-globstar segments on Linux — but MatchesDirectoryName checks _cultureSensitiveDirectoryPatterns first and never consults that array; the regex is built with RegexOptions.IgnoreCase unconditionally.
Scenario (Linux): include src/*/obj/**/*.cs (multi-segment wildcard dir → legacy regex mode) with /repo/src/p/obj/a.cs and /repo/src/p/Obj/b.cs. Legacy pushes the directory pattern into EnumerateDirectories(path, "obj") → MatchCasing.PlatformDefault → case-sensitive, so Obj is never visited. Optimized returns b.cs too.
Fix: pass the case-sensitivity flag into CreateCultureSensitiveDirectoryRegex and set RegexOptions.IgnoreCase only when !filesystemCaseSensitive || globstarSeen. Add a test combining filesystemCaseSensitive: true with useCultureSensitiveMatch: true (the existing test at MSBuildPathMatcher_Tests.cs:166-180 never combines them).
H6 — Callback driver passes a null filename pattern → 12× allocation on low-selectivity directories
FileMatcher.cs:2679-2685 — cached callback :152-177, RemoveProjectDirectory :740-770
The callback driver calls _getFileSystemEntries(FileSystemEntity.Files, directory, pattern: null, ...). With pattern == null the cached callback skips the IsFileNameMatch filter (:170-172) and runs RemoveProjectDirectory(...).ToList() — a Substring per entry — over every file in the directory. Legacy passes SearchData.Filespec and filters before stripping (:1097-1105).
Measured (100 dirs × 2 .cs + 200 .txt, entry cache pre-populated to isolate filter+strip): legacy 138.5 KB → optimized 1 670.5 KB (12.1×); end-to-end with a cold cache 4.05 MB → 5.55 MB. This is an allocation regression in the path the PR claims to optimize.
(Disputed sub-claim, for the record: this does not corrupt or bloat the entry cache — the cache key is "F"/"D"/"A" + ";" + path and the factory always enumerates with "*", so the pattern is not part of the key.)
Fix: pass state.SearchData.Filespec when non-null (the matcher re-checks the name anyway), or strip lazily only for entries that passed includeMatcher.MatchesFileName.
H7 — No caching of matchers or regexes; ~4× the Regex compiles of the legacy path
MSBuildPathMatcher.cs:66-146, 622, 659; FileMatcher.cs:2755-2787 (CreatePathMatcher), :2233
A MSBuildPathMatcher is constructed per GetFiles call plus one per exclude spec, each allocating a List<string> + a string per segment (segment.ToString() allocates even for the constant "**") + the [.. patterns] copy + up to four parallel arrays. None of the new Regex(...) calls are cached — contrast the legacy s_regexCache at :73. Measured 8.3 µs / 6 216 B per matcher for **/src/**/a/** + *.cs; ~4 584 B for a typical "1 include + 10 SDK excludes" set.
Worse, GetFileSearchData still builds a Regex at :2233 that the optimized path never executes — it only tests it for null (:2767, 2772, 2780).
Fix: add a static ConcurrentDictionary cache keyed by (wildcardDirectoryPart, filePattern, flagBits) (the flags are deterministic per spec), mirroring s_regexCache; intern the "**" segment; and skip materializing the throwaway RegexFileMatch when the optimized driver is selected (pass the matchWithRegex boolean instead).
H8 — Capability-interface trap: WindowsFileSystem silently inherits SupportsDirectEnumeration => true
src/Framework/FileSystem/ManagedFileSystem.cs:17, 35; WindowsFileSystem.cs:34, 42-55
SupportsDirectEnumeration is a non-virtual property on the non-sealed ManagedFileSystem. WindowsFileSystem : ManagedFileSystem overrides EnumerateFiles/EnumerateDirectories with native FindFirstFileEx enumeration whose semantics differ (8.3 short names, "*" special-casing) — i.e. it is not a pass-through, yet it inherits the claim that it is. Unreachable today, but MSBuildOnWindowsFileSystem.cs:13 carries the TODO "Remove this class and replace with WindowsFileSystem" — executing that TODO silently enables direct enumeration for a non-conforming file system.
// ManagedFileSystem
public virtual bool SupportsDirectEnumeration => true;
// WindowsFileSystem
public override bool SupportsDirectEnumeration => false;H9 — Result cache key encodes the resolved enum, not the effective driver
FileMatcher.cs:2056 ("l;"/"o;"), :1991-1996, :2445-2461
GetFilesForImplementation falls back to GetFilesImplementation when !SupportsOptimizedImplementation, when an entry cache is present with zero excludes, or when RequiresLegacyImplementation — all of which still write under "o;". And CreatePathMatcher deliberately configures different matching semantics per driver (filesystemCaseSensitive, useWin32FileNameMatch, useTrailingDotRegex all keyed off useCachedCallback). So under MsBuildCacheFileEnumerations (CloudBuild), the process-wide s_cachedGlobExpansions can serve a Linux case-sensitive direct-driver result to a case-insensitive callback-driver caller under the identical key.
(Fairly noted: the underlying Linux case split is pre-existing legacy behavior — the documented back-compat hack at :1088-1092 — and the old key had no discriminator either, so the PR doesn't make it worse. But the PR is what makes the key look like it discriminates.)
Fix: encode the semantic knobs (_usesFileSystemEntryCache / _allowDirectEnumeration, or a hash of the CreatePathMatcher option tuple) rather than the enum: "l;" / "oc;" / "od;".
H10 — Test gaps on exactly the code that broke
The differential/oracle strategy is excellent and the corpus reuse (GetFilesComplexGlobbingMatchingInfo) is the right call. Tests build clean and pass (61/0/7 on net11.0, 29/0 on net472). But:
| Gap | Location |
|---|---|
FailOnDriveEnumeratingWildcard (env var = "1") never tested — both hand-rolled return tuples (:2532, :2575) have zero coverage |
FileMatcherOptimized_Tests.cs:399-435 |
Symlink loops during traversal untested — only the case where the symlink is the enumeration root; ShouldRecurseIntoEntry's loop guard (:2972) is never hit |
:915-946 |
Result-cache hit path never executed (ClearCaches() before every query) — a key that ignored excludes or the implementation would still pass |
:208-235 |
Differential tests can pass vacuously — 216 combinations with no independent expectation and no ShouldNotBeEmpty on the legacy oracle |
:31-89, :296-360 |
Wave-disabled path asserts only the enum value; never calls GetFiles |
:271-295 |
stripProjectDirectory == false (rooted include + non-null project dir) never exercised — the shape that broke in H2 |
:237-269 |
Weak assertions: ShouldNotBeEmpty() as the only check in 4 tests |
:379, :394, :831-832, :799 |
MatchesFilesInDirectory (the hottest entry point, :2656) has no direct test; useWin32FileNameMatch: true never set; multi-globstar NFA never exceeds StackStateCount so the pool fallback is untested |
MSBuildPathMatcher_Tests.cs |
Every one of C1–C2/H1–H5 would have been caught by adding these cases to the differential matrix.
🟡 Medium
- No design doc for a 3 393-line engine rewrite.
documentation/specs/has nothing on globbing; the ChangeWaves link (#12072) is an open investigation issue ("Investigate the usage/performance of the enumerator patterns shown in the Touki library"), not an approved design. Nothing records the equivalence contract, the driver matrix, the case-sensitivity model, or the rollback plan. → Adddocumentation/specs/optimized-file-matching.md; link the PR in ChangeWaves.md as the neighbouring entries do. (Xplat Branch Merge #10, Changed 'canceled' back to 'cancelled" #18) - Driver selection is a nested ternary over four predicates with zero comments (
:2443-2463,:2466-2468,:2837-2851). The most surprising clause —_usesFileSystemEntryCache && excludeSpecsUnescaped?.Count is not > 0, which sends cached, exclude-free globs (bare**/*.cs) back to legacy — is entirely unexplained, andCanUseDirectEnumerationre-derives predicatesSupportsOptimizedImplementationalready tested. → Extractprivate FileMatcherDriver SelectDriver(...)returning an explicit enum, one XML-doc'd rule per branch. (Xplat Branch Merge #10, Add build status badge #12) - Blast radius is inverted vs intuition.
EvaluationContextalways supplies a cache (EvaluationContext.cs:69-71), so SDK globs withDefaultItemExcludestake the new matcher while the same glob without excludes stays legacy — i.e. the wave lands squarely on SDK default items.EngineFileUtilitiesusesFileMatcher.Default(null cache) → the third, direct driver. Run an end-to-end differential over dotnet/sdk, dotnet/runtime and a WPF/WinForms project before flipping the wave. (Fixed some typos #15) - Zero diagnostics and no per-feature kill switch.
git diff ... | grep '^+.*///'→ 0 matches; nothing indicates which of three drivers ran, andFileMatcherImplementationhas noTraits/env-var binding, so the only field mitigation isMSBUILDDISABLEFEATURESFROMVERSION=18.11— which also disables two unrelated shipped features. Precedent exists two layers up:EngineFileUtilities.cs:210-213gates onTraits.Instance.LogExpandedWildcards. → AddMSBUILDFILEMATCHERIMPLEMENTATION+ emit the selected driver underLogExpandedWildcards. (Migrate from MSTest to xunit #6) CreateCultureSensitiveFileRegex/CreateCultureSensitiveDirectoryRegexare hand-copied reimplementations ofAppendRegularExpressionFromFilename(FileMatcher.cs:1359-1389), duplicating both quirks (the*.*collapse guard, the[^.].expansion of?). The escaping tables have already drifted:AppendRegexLiteral(:663) escapes\ . $ ^ { [ ( | ) + ]whileIsSpecialRegexCharacter(FileMatcher.cs:1421) escapes$ ( ) + . [ ^ { |—\and]differ. The non-culture path already reusesFileMatcher.RegularExpressionFromFileSpec(:137), proving reuse is possible. (Xplat Branch Merge #10, Add build status badge #12)- Three
TryGetRelativeDirectoryimplementations (:2800local static,:3057/:3067overloads →:3077core) that are not equivalent: the local copy hard-codesOrdinalIgnoreCaseand omits the "base already ends in a separator" branch, so it returnsfalsefor("/", "/foo")— a rooted exclude at/would silently stop excluding on Unix. → Collapse to one class-scope method. (Add build status badge #12, Fixed typos #22) IgnoreInaccessible = true(:3021) is a behavior change. Legacy catchesSecurity/UnauthorizedAccessExceptionper directory and returns[]for that whole directory (:395-404,:445-456); the new path skips per entry, so it returns more results. Not a security issue, but a divergence with no test. (Fixed typos #22)- Benchmarks don't measure the changed paths.
LegacyWarmCache/OptimizedWarmCache(FileMatcherBenchmark.cs:102-108) hit_cachedGlobExpansionsand return the memoized array — measured cold 35.30 ms vs warm 0.0086 ms (~4 100×); they benchmark key construction + a dictionary lookup +ToArray. The tree ([Params(32, 512)], three first-level directories, depth 4-5) structurally cannot expose the parallelism loss, since legacy's fan-out is capped at 3 there. Matcher construction is hidden in[GlobalSetup]. (dir.targets: correctly enclose 'Exists' condition in single quotes #3) MSBuildGlob(Remove/Update matching) still uses the legacy regex engine (src/Build/Globbing/MSBuildGlob.cs:185-211). Enumeration switches under the wave while matching does not — a divergence now manifests as an item that appears in the include set but is not matched by aRemove, which is far harder to diagnose. → Add a differential test assertingMSBuildGlob.IsMatchagrees with the optimized enumeration over the shared corpus. (Xplat Branch Merge #10)- Result ordering flips DFS → BFS (
FileSystemEnumerator's pending-directory queue). Legacy order was already nondeterministic underParallel.ForEach, so this is informational — but worth a note in the wave documentation. (Fixed typos #22)
⚪ Low / Nit
- Dead tracking machinery:
trackedAction/trackedExcludeFileSpec(:2555-2556) are assigned once and never reassigned after commitc889a2c8d3;trackedActionis alwaysRunSearch,trackedExcludeFileSpecalwaysstring.Empty. Delete both. (Add build status badge #12) ResolvedImplementationis evaluated twice per call (:1991for the key,:2440for dispatch) — each callsChangeWaves.AreFeaturesEnabled. Resolve once and pass it down. (Fix casing of file references in csprojs #2, dir.targets: correctly enclose 'Exists' condition in single quotes #3)- Duplicated redundant syscall:
ShouldSkipRecursiveDirectory(state.BaseDirectory)at:2612is repeated at:2651for the same directory. - Indentation:
IsWin32FileNameMatch(:1708-1723),TranslateWin32Expression(:2853-2862) andShouldSkipRecursiveDirectory(:2864-2879) are indented at 4 spaces inside an 8-space class body, with#ifdirectives and continuation arguments misaligned. Doesn't match.editorconfig. - Idiomatic C#:
literalExcludes ??= new HashSet<string>()(:2572) vs(excludesToMatch ??= [])(:2746) in the same method;literalExcludes?.Contains(file) == true(:2686) →is true;AttributesToSkip = 0(:3022) →FileAttributes.None;projectDirectory[projectDirectory.Length - 1](:3131) vsbaseSpan[^1](:3084); manualforcopy in the enumerator ctor (:2924-2934) →[.. excludes];EndsWith(".", Ordinal)(MSBuildPathMatcher.cs:133) →EndsWith('.');excludeSpecs?.Count is not > 0→excludeSpecs is null or { Count: 0 }. (Support yaml/json #16, Add build status badge #12) - Duplicated helpers:
MatchesName's hand-rolled case-sensitive matcher (:521-557) duplicatesFileMatcher.IsMatchwith different edge-case semantics (IsMatchthrowsInternalErroron empty input; the copy returnstruefor("", "*"));GetFileName(:491-502) re-implements spanPath.GetFileName(the repo idiom is atFileMatcher.cs:1696-1706);IsCandidateDirectorySeparator(:843) duplicatesFileUtilities.IsSlash. (Add build status badge #12) - Dead-ish API: both
MatchesFileoverloads (:153,:163) are referenced only by tests and benchmarks. (Add build status badge #12) - Naming ([Nefarious Grammar] Bad sentence structure #14):
_allowDirectEnumerationgates the entire optimized implementation, not just direct enumeration →_allowOptimizedImplementation;IsWin32FileNameMatchis also used for directories →IsWin32NameMatch;useCultureSensitiveMatchactually means "legacy regex mode";MSBuildPathMatchermatches spec fragments, not paths;DirectoryMatchType.MayContainMatchingFilesalso covers "definitely matches". The 9-parameter, 7-bool constructor is call-site-safe (named args used in production) but the flags are undocumented → consider areadonly struct MSBuildPathMatcherOptions. ArgumentNullException.ThrowIfNullin an internal ctor (MSBuildPathMatcher.cs:77-78) — the file's convention for internal invariants isAssumed.True/InternalError.Throw(FileMatcher.cs:882-890,:1746). (Issue Guide wiki entry does not exist, README references it #5)- Lost rationale: the legacy inline symlink check carries a 10-line comment enumerating what the heuristic misses (mutual A→B/B→A links, issue FileState/Copy task support for symlinks #703);
ShouldSkipRecursiveDirectory, now the shared implementation for both new drivers, carries none of it. Move the comment. (Changed 'canceled' back to 'cancelled" #18) - Provenance header (
MSBuildPathMatcher.cs:1-13) pins the touki commit (good) but omits the URL, license statement and adaptation list that the existing precedent (ValueStringBuilder.cs:1-12) includes;THIRDPARTYNOTICES.txthas no touki entry. Worth confirming with repo owners now that ~1 100 adapted lines have landed. (Changed 'canceled' back to 'cancelled" #18) - Scope: the
InnerExceptionsAreAllIoRelated→IsIoRelatedExceptionOrAggregaterefactor (:3419) touches the legacy path. It is provably behavior-preserving (the oldcatch (AggregateException) when (...)re-tested the same predicate inside, making thethrow;unreachable dead code), but it should be split out or called out in the PR description so a future glob-exception regression isn't bisected into this PR. Likewise noteShouldEnforceMatchingprivate → internal (:319) as intentional. (Removing redundant contructors (empty is provided by default) #20) - Tests (Truncated source file #4): no test follows
MethodUnderTest_Scenario_ExpectedResult;OptimizedMatchesLegacyForPatternMatrix(:297) uses nestedforeachinstead of[Theory]/MemberData, so failing combinations aren't individually reported;ExternalStyleFileSystemWithoutEntryCacheUsesLegacyImplementation(:802-814) asserts the opposite of its name (ShouldBe(Optimized)) and discards theGetFilesresult; manualtry/finallyforCurrentCulture(:611-654,MSBuildPathMatcher_Tests.cs:252-274) and for the ChangeWave env var (:274-291) contradictstests.instructions.md—TestEnvironment.Disposealready resets both. using Microsoft.Build.Utilities;in FileMatcher.cs (:28) is not a layering inversion — it resolves to the Framework-internalBufferScope<T>/ValueStringBuilderin the same assembly — but it collides with the public namespace of the higher-layerMicrosoft.Build.Utilities.Coreand reads as one. Add a one-line comment or move those helpers toMicrosoft.Build.Shared. (Xplat Branch Merge #10)IDirectFileSystemEnumeration.SupportsDirectEnumerationhas no member XML doc and the implementor obligation (results must be set-equivalent toSystem.IOenumeration; no filtering/redirection/caching) is unstated. Also worth one sentence in theMSBuildFileSystemBasedocs noting that custom file systems permanently take the non-direct path. (Nuked ThreadingUtilities.cs #8, Changed 'canceled' back to 'cancelled" #18)FileSystemEnumerator<T>ordering invariant is undocumented. I verified against the runtime sources thatDirectoryFinished()callsOnDirectoryFinishedbeforeDequeueNextDirectory, and that both the Windows and Unix implementations use aQueue<>(BFS), so_fileStateValidcaching is sound and parent/child entries can never interleave. But that is an implementation detail, not a documented contract — and MSBuild also runs the separately-versionedMicrosoft.IO.Redistport on net472. AddDebug.Assert(directory.SequenceEqual(_stateDirectory))inEnsureFileStateplus a comment recording the relied-upon ordering. (Fixed some typos #13)
Suggested path forward
- Fix C1 (exclude rebasing) and add the include-under-excluded-directory case to the differential matrix — this one alone can silently break SDK builds.
- Fix C2 by restoring parallel fan-out, then re-run the benchmarks on a wide/deep tree and paste BDN output into the PR description.
- Fix H1–H5 (all confirmed divergences) and extend
OptimizedMatchesLegacyinline data to cover each:./-prefixed includes, trailing-separator project directories, trailing-dot include+exclude pairs,.-in-wildcard segments, doubled**, and (Linux) case-differing directory names in regex mode. - Fix H6/H7 (allocation + caching), close the H10 test gaps, then add the design doc and driver-selection refactor.
- Consider landing the matcher + its unit tests first, and the FileMatcher wiring + direct-enumeration driver as follow-ups — 3 393 lines in two commits is a lot to review as one unit, and the split would make bisecting any field regression far cheaper.
Read-only review — no files were modified. All validation harnesses created during review were deleted; git status is as found.
|
Two things that I think are additive to the review above rather than a repeat of it. I re-measured independently from the evaluation side (OrchardCore modules, same-binary ChangeWave A/B, MSBuild's own event-source markers), and I land on the same conclusion as C2 — so treat the root cause there (lost 1. The direct driver is unreachable from evaluation-time globs
return _allowDirectEnumeration
&& !_usesFileSystemEntryCache // <--
&& literalExcludes is null
&& _fileSystem is IDirectFileSystemEnumeration { SupportsDirectEnumeration: true }
&& (!string.IsNullOrEmpty(projectDirectory) || Path.IsPathRooted(fileSpec));and
So for item globs during evaluation the direct
Note this is not an argument that the guard is wrong — bypassing the entry cache would stop the 6 globs in a typical project from sharing directory entries. It just means the optimized path that evaluation actually gets is the one that was least covered by the PR's headline benchmarks. 2. What C2 costs at the evaluation level, not the component levelC2 measures
For Two qualifiers, both of which cut in the PR's favour and are worth recording:
Happy to share the harness if useful; it's event-source markers plus an injected |
Re-measured at f08c806 with OrchardCore (241 projects) as the primary subject instead of a console app, which understates globbing by 4x and hides everything that only appears at project-graph scale. Four findings, each measured: 1. A single target framework declared as plural <TargetFrameworks> evaluates every project twice. On a controlled 20-project tree that is 2.6x wall clock and 40 evaluations per build instead of 20. 2. -mt is worth 2.5x on the inner loop and 3x less evaluation CPU, because it collapses per-node cache duplication. The server on its own is not measurable above noise for a multi-project build. 3. Evaluation results are never reused between builds: the same 81 evaluations run on every consecutive build of an unchanged tree. Only XML is cached. 4. The optimized FileMatcher in #14663 regresses evaluation-time globbing, worsening with tree size, and its fast path is unreachable from evaluation because EvaluationContext always supplies a file entry cache. Also records that this repository's bootstrap does not reproduce shipping default behaviour: the pinned SDK predates the dotnet/sdk change that enables MSBuild Server by default, so server measurements must set MSBUILDUSESERVER=1 explicitly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 19d11264-4b3d-4578-9e62-1439c078c89d
b63b050 to
743d069
Compare
|
@ViktorHofer, thank you for the detailed review and independent evaluation measurements. I rebased onto current Critical findings
Final sampled complete-project evaluation is High findings
Additional compatibility workRegex-backed filesystem globs now use Measured in-situ fallbacks retain legacy for no excludes, unrelated fixed excludes, unsupported lexical shapes, invalid wildcard exact-identity excludes, drive-wide requests, null-project relative globs, and custom/non-capable filesystems. Recursive SDK default excludes ( Post-rebase validation:
The PR description now leads with complete-project evaluation rather than direct traversal. Would you please take another look at the updated implementation and evidence? Fresh PR CI is pending. |
Add a ChangeWave-controlled optimized path for wildcard matching and filesystem traversal while retaining the legacy implementation as a compatibility fallback. Adapt the matcher design from Touki, preserve MSBuild-specific semantics, and add differential tests and benchmarks.
0e70af8 to
019c19c
Compare
|
The macOS Core failure in build 20260818.45 is a cross-assembly test race rather than a FileMatcher failure.
The FileMatcher tests added or changed by |
|
https://dev.azure.com/devdiv/DevDiv/_git/VS/pullrequest/771156 |
ViktorHofer
left a comment
There was a problem hiding this comment.
I need to be honest that I didn't have time to review MSBuildPathMatcher.cs and FileMatcher.cs in detail but the change overall looks great.
Be careful with such an A/B measurement. The change wave disables other features as well that we recently added. |
Fixes #15126. The Wave 18.11 optimization in #14663 canonicalizes the direct enumerator's include root but leaves exclude roots lexical. Shared `.` or `..` segments then prevent exclusions from matching physical enumeration paths. Rebase each applicable Exclude's shared include-root prefix onto the canonical enumeration root, preserving its lexical suffix and output-path spelling. This restores legacy behavior without a new change wave. Add coverage for relative and absolute include roots, file and subtree exclusions, and lexical-path compatibility. --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Backport of #15127 to `vs18.11`. Fixes #15126. Work item (Internal use): [3069262](https://dev.azure.com/devdiv/DevDiv/_workitems/edit/3069262) ### Summary Fix incorrect wildcard exclusions in the Wave 18.11 optimized file enumerator. ### Customer Impact Files are silently included in items that should be excluded. This can break customer scenarios. Reported as [VS Feedback 11156765](https://developercommunity.visualstudio.com/t/MSBuild-1811042713-fails-to-exclude-i/11156765), severity "I'm unable to use this version." A workaround is available via changewave 18.11 (`MSBUILDDISABLEFEATURESFROMVERSION=18.11`), but this changes other behavior and is intended only as a quick mitigation. ### Regression? Yes, from #14663. ### Testing Validation reported in the original PR #15127: - Unit tests - Validation on customer-provided repro from feedback issue - Added regression test coverage ### Risk Low. The change is localized and the updated codepath can still be avoided entirely via changewave if necessary. Co-authored-by: Rainer Sigwald <raines@microsoft.com> Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Backport of #15127 to vs18.12 Fixes #15126. Work item (Internal use): N/A (VS hasn't locked down yet for 18.12) ### Summary Fix incorrect wildcard exclusions in the Wave 18.11 optimized file enumerator. ### Customer Impact Files are silently included in items that should be excluded. This can break customer scenarios. Reported as [VS Feedback 11156765](https://developercommunity.visualstudio.com/t/MSBuild-1811042713-fails-to-exclude-i/11156765), severity "I'm unable to use this version." A workaround is available via changewave 18.11 (`MSBUILDDISABLEFEATURESFROMVERSION=18.11`), but this changes other behavior and is intended only as a quick mitigation. ### Regression? Yes, from #14663. ### Testing Validation reported in the original PR #15127: - Unit tests - Validation on customer-provided repro from feedback issue - Added regression test coverage ### Risk Low. The change is localized and the updated codepath can still be avoided entirely via changewave if necessary. Co-authored-by: Rainer Sigwald <raines@microsoft.com> Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Summary
FileMatcher.GetFilesnow selects a legacy, optimized callback, or optimized direct driver before whole-glob cache lookup. The primary target is cache-backed SDK-style project evaluation: in the final same-binary benchmark matrix, complete isolated project evaluations ran 10-13% faster and allocated 16-34% less than the Wave-disabled baseline.The optimized path is not applied universally. Cache-backed shapes that measured neutral or slower remain on the legacy driver, while compatibility-sensitive requests are routed conservatively to either legacy or callback traversal.
The matcher is adapted from Touki at
9d925032a5e7d100c9380a7fe40b9ef64527bcab. Existing MSBuild behavior and differential tests remain the compatibility oracle.No public API surface or new warning/error diagnostic is introduced.
Fixes #12072.
Implementation
EvaluationContext.FileSystemEnumerator<T>traversal for built-in filesystems that explicitly advertise set-equivalent direct enumeration.FileMatcherexpansion and theMSBuildGlobdescriptors used for Include, Exclude, and Remove matching.Dialect semantics, matcher implementation, and enumeration driver remain separate internal concepts. This avoids adding a public dialect API while leaving room for Touki-aligned compiled specifications later.
Driver selection
The cache-backed selection rule is based on whether a wildcard exclude can overlap the include root, not simply whether excludes exist.
bin/**;obj/**excludes below asrcinclude**/bin/**;**/obj/**excludesDrive-wide and drive-relative specifications, trailing-directory specifications, ancestor excludes that cannot be safely rebased, relative excludes without a project root, custom callback delegates, and non-capable filesystems use legacy traversal.
Uncached relative includes without a project directory, literal excludes, and invalid wildcard excludes use optimized callback traversal rather than direct traversal. This preserves relative output and exact-identity filtering semantics.
Compatibility
Autoresolves to the optimized implementation only when Change Wave 18.11 is enabled. SettingMSBUILDDISABLEFEATURESFROMVERSION=18.11restores the legacy FileMatcher implementation, along with opting out of the other features in that Change Wave.Regex-backed filesystem globs now use culture-invariant case folding by default.
MSBUILDUSELEGACYCULTURESENSITIVEFILEGLOBS=1restores current-culture folding without disabling unrelated Wave 18.11 behavior. The escape hatch is read from the current environment so reused worker nodes honor refreshed build environments.Compatibility coverage includes DOS wildcards, trailing-dot behavior, Linux casing and literal backslashes, rooted and lexical paths, invalid excludes, injected filesystems, symlinks, I/O failures, concurrent cache population, drive-wide disposition, and Include/Remove culture consistency.
Evaluation performance
Final Windows 11
.NET 11ShortRun results for complete project evaluation, comparing the same binary with Wave 18.11 enabled and disabled:Every final-code isolated sample was faster and lower-allocation. A separate full-sampling checkpoint measured 0.80x-0.90x time and 0.66x-0.88x allocation.
A targeted full shared-context run measured 1.01x legacy time and 1.02x allocation. Shared contexts are expected to remain close to neutral because the whole-glob result is already cached.
The benchmark validates every sorted
@(Compile)identity before measurement, not only item counts.Validation
FileMatcherOptimized_Tests, Releasenet472/x86: 107 passed, 19 expected skips.FileMatcherOptimized_Tests, Debugnet11.0: 108 passed, 19 expected skips.net11.0andnet472: 2,133 passed, 70 expected skips.MSBuildGloband evaluation-culture tests: 202 passed on Windows and 101 passed on Linux.net11.0andnet472: 46 passed, 4 expected skips.netstandard2.0source-build compile and benchmark build with warnings as errors: passed.