perf: reduce TreeNodeFilter allocations via IReadOnlyList and ReadOnlySpan - #8238

Closed
Abdelghani Moussaid (abdelghani-moussaid) wants to merge 6 commits into
microsoft:mainfrom
abdelghani-moussaid:optimize-tree-node-filter
Closed

perf: reduce TreeNodeFilter allocations via IReadOnlyList and ReadOnlySpan#8238
Abdelghani Moussaid (abdelghani-moussaid) wants to merge 6 commits into
microsoft:mainfrom
abdelghani-moussaid:optimize-tree-node-filter

Conversation

@abdelghani-moussaid

@abdelghani-moussaidAbdelghani Moussaid (abdelghani-moussaid) commented May 14, 2026

Copy link
Copy Markdown

Performance optimization for TreeNodeFilter to eliminate allocations during test discovery.

Key Changes:

  • Widened SubExpressions to IReadOnlyList to avoid IEnumerator boxing.
  • Implemented ReadOnlySpan-based recursion for .NET 8.0+ to eliminate O(N) string fragment allocations.
  • Refactored Span path to use foreach loops to satisfy the CS9108 ref-struct capture constraint.

Verification Results:

  • BenchmarkDotNet: 160 B -> 0 B allocations on .NET 8.0. Execution time reduced by ~45%.

Unit Tests: All local TreeNodeFilter tests passed.


Related

…ySpan
- Widen SubExpressions to IReadOnlyList to enable optimized LINQ paths.
- Implement ReadOnlySpan-based matching for .NET 8.0+ to eliminate
string fragment allocations.
- Use procedural loops in Span path to avoid ref-struct capture (CS9108).
@Evangelink

Copy link
Copy Markdown
Member

Code Review

Thanks for the perf work! The intent is solid, but a few things in the implementation don't match the "zero allocations" claim, and the PR description has a couple of inaccuracies worth fixing. Detailed findings below.

🟠 Major

1. foreach (FilterExpression expr in subexprs) over IReadOnlyList<T> allocates an enumerator.

OperatorExpression.SubExpressions is statically typed as IReadOnlyList<FilterExpression> (even though the backing field is FilterExpression[]). IReadOnlyList<T> exposes no struct enumerator, so foreach binds to IEnumerable<T>.GetEnumerator() which returns a heap-allocated enumerator. This happens once per Or/And node, on a hot path — directly contradicting the "0 B" goal.

The existing string overload deliberately avoids this with an indexed for loop:

for(inti=0;i<subexprs.Count;i++){if(MatchFilterPattern(subexprs[i],testNodeFragment,properties)){ ...}}

Please use the same pattern in the Span overload. As a side note, the PR description says the foreach change was needed "to satisfy the CS9108 ref-struct capture constraint" — CS9108 is about lambda/local-function capture of ref struct parameters, and there is no lambda or local function here. The original for loop pattern compiles fine with ReadOnlySpan<char> arguments.

2. subexprs.Single() for the Not case.

caseOperatorExpression{Op:FilterOperator.Not,SubExpressions: var subexprs }:return!MatchFilterPattern(subexprs.Single(),testNodeFragment,properties);

vs. the string overload:

caseOperatorExpression{Op:FilterOperator.Not,SubExpressions:[varsingleSubExpr]}:return!MatchFilterPattern(singleSubExpr,testNodeFragment,properties);

Issues:

  • Unnecessary LINQ call where [var singleSubExpr] (or subexprs[0]) is allocation-free and trivially intent-revealing.
  • Changes the exception contract: InvalidOperationException from LINQ vs. ApplicationStateGuard.Unreachable(). Upstream ParseFilter validation makes this practically unreachable, but having two overloads diverge in exception contract for the same logical case is a future trap.

Please restore the list pattern so the two overloads match.

🟡 Moderate

3. Code duplication. After fixing #1 and #2 the two overloads will be near-identical; consider either:

  • keeping them byte-identical except for the fragment type and adding a short comment cross-referencing each other, or
  • having the string overload simply call .AsSpan() and forward to the Span overload on NET8_0_OR_GREATER, falling back to the original impl only on netstandard2.0.

The second option eliminates the duplication on the TFMs where it actually matters.

4. Tests. The PR adds no tests. With both overloads shipped (string on netstandard2.0, Span on net8+), a parity regression would not be caught on one TFM. Suggest adding a small test that exercises Or (multi-sub), And (multi-sub), Not (1 sub), ValueAndPropertyExpression, and NopExpression so both code paths are validated against the same inputs. An allocation guard on the Span path (AllocatedBytes style) would also lock in the zero-allocation claim once #1 and #2 are fixed.

5. PR description nits.

  • "Widened SubExpressions to IReadOnlyList to avoid IEnumerator boxing" isn't in this diff — OperatorExpression.SubExpressions was already widened in [Efficiency Improver] perf: eliminate LINQ closure allocations in TreeNodeFilter #8035.
  • "Refactored Span path to use foreach loops to satisfy the CS9108 ref-struct capture constraint" — as noted above, CS9108 doesn't apply here.
  • The "~45% time / 160 B → 0 B" benchmark numbers are plausible for a single-fragment ValueExpression input but won't generalize to Or/And/Not trees (which currently regress on allocation, per Initial commit! 🎉 #1). Worth scoping that statement.

✅ Looks good

  • TFM gating with #if NET8_0_OR_GREATER is correct (Regex.IsMatch(ROS<char>) is available on .NET 7+; the only other TFM is netstandard2.0).
  • No public API changes; method stays private static.
  • Regex is thread-safe; no shared mutable state introduced.
  • MatchProperties correctly left out of scope — it operates on PropertyBag and has its own struct-enumerator optimization.

Once #1 and #2 are addressed, this becomes a solid micro-optimization that genuinely lands at zero allocations across all expression shapes.

@EvangelinkAmaury Levé (Evangelink) added the needs/author-feedback Waiting on the original author. label May 25, 2026
@microsoft-github-policy-servicemicrosoft-github-policy-serviceBot removed the needs/author-feedback Waiting on the original author. label May 29, 2026
Sign up for freeto 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

@abdelghani-moussaid@Evangelink
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

perf: reduce TreeNodeFilter allocations via IReadOnlyList and ReadOnlySpan - #8238

Closed
Abdelghani Moussaid (abdelghani-moussaid) wants to merge 6 commits into
microsoft:mainfrom
abdelghani-moussaid:optimize-tree-node-filter
Closed

perf: reduce TreeNodeFilter allocations via IReadOnlyList and ReadOnlySpan#8238
Abdelghani Moussaid (abdelghani-moussaid) wants to merge 6 commits into
microsoft:mainfrom
abdelghani-moussaid:optimize-tree-node-filter

Conversation

@abdelghani-moussaid

@abdelghani-moussaidAbdelghani Moussaid (abdelghani-moussaid) commented May 14, 2026

Copy link
Copy Markdown

Performance optimization for TreeNodeFilter to eliminate allocations during test discovery.

Key Changes:

  • Widened SubExpressions to IReadOnlyList to avoid IEnumerator boxing.
  • Implemented ReadOnlySpan-based recursion for .NET 8.0+ to eliminate O(N) string fragment allocations.
  • Refactored Span path to use foreach loops to satisfy the CS9108 ref-struct capture constraint.

Verification Results:

  • BenchmarkDotNet: 160 B -> 0 B allocations on .NET 8.0. Execution time reduced by ~45%.

Unit Tests: All local TreeNodeFilter tests passed.


Related

…ySpan
- Widen SubExpressions to IReadOnlyList to enable optimized LINQ paths.
- Implement ReadOnlySpan-based matching for .NET 8.0+ to eliminate
string fragment allocations.
- Use procedural loops in Span path to avoid ref-struct capture (CS9108).
@Evangelink

Copy link
Copy Markdown
Member

Code Review

Thanks for the perf work! The intent is solid, but a few things in the implementation don't match the "zero allocations" claim, and the PR description has a couple of inaccuracies worth fixing. Detailed findings below.

🟠 Major

1. foreach (FilterExpression expr in subexprs) over IReadOnlyList<T> allocates an enumerator.

OperatorExpression.SubExpressions is statically typed as IReadOnlyList<FilterExpression> (even though the backing field is FilterExpression[]). IReadOnlyList<T> exposes no struct enumerator, so foreach binds to IEnumerable<T>.GetEnumerator() which returns a heap-allocated enumerator. This happens once per Or/And node, on a hot path — directly contradicting the "0 B" goal.

The existing string overload deliberately avoids this with an indexed for loop:

for(inti=0;i<subexprs.Count;i++){if(MatchFilterPattern(subexprs[i],testNodeFragment,properties)){ ...}}

Please use the same pattern in the Span overload. As a side note, the PR description says the foreach change was needed "to satisfy the CS9108 ref-struct capture constraint" — CS9108 is about lambda/local-function capture of ref struct parameters, and there is no lambda or local function here. The original for loop pattern compiles fine with ReadOnlySpan<char> arguments.

2. subexprs.Single() for the Not case.

caseOperatorExpression{Op:FilterOperator.Not,SubExpressions: var subexprs }:return!MatchFilterPattern(subexprs.Single(),testNodeFragment,properties);

vs. the string overload:

caseOperatorExpression{Op:FilterOperator.Not,SubExpressions:[varsingleSubExpr]}:return!MatchFilterPattern(singleSubExpr,testNodeFragment,properties);

Issues:

  • Unnecessary LINQ call where [var singleSubExpr] (or subexprs[0]) is allocation-free and trivially intent-revealing.
  • Changes the exception contract: InvalidOperationException from LINQ vs. ApplicationStateGuard.Unreachable(). Upstream ParseFilter validation makes this practically unreachable, but having two overloads diverge in exception contract for the same logical case is a future trap.

Please restore the list pattern so the two overloads match.

🟡 Moderate

3. Code duplication. After fixing #1 and #2 the two overloads will be near-identical; consider either:

  • keeping them byte-identical except for the fragment type and adding a short comment cross-referencing each other, or
  • having the string overload simply call .AsSpan() and forward to the Span overload on NET8_0_OR_GREATER, falling back to the original impl only on netstandard2.0.

The second option eliminates the duplication on the TFMs where it actually matters.

4. Tests. The PR adds no tests. With both overloads shipped (string on netstandard2.0, Span on net8+), a parity regression would not be caught on one TFM. Suggest adding a small test that exercises Or (multi-sub), And (multi-sub), Not (1 sub), ValueAndPropertyExpression, and NopExpression so both code paths are validated against the same inputs. An allocation guard on the Span path (AllocatedBytes style) would also lock in the zero-allocation claim once #1 and #2 are fixed.

5. PR description nits.

  • "Widened SubExpressions to IReadOnlyList to avoid IEnumerator boxing" isn't in this diff — OperatorExpression.SubExpressions was already widened in [Efficiency Improver] perf: eliminate LINQ closure allocations in TreeNodeFilter #8035.
  • "Refactored Span path to use foreach loops to satisfy the CS9108 ref-struct capture constraint" — as noted above, CS9108 doesn't apply here.
  • The "~45% time / 160 B → 0 B" benchmark numbers are plausible for a single-fragment ValueExpression input but won't generalize to Or/And/Not trees (which currently regress on allocation, per Initial commit! 🎉 #1). Worth scoping that statement.

✅ Looks good

  • TFM gating with #if NET8_0_OR_GREATER is correct (Regex.IsMatch(ROS<char>) is available on .NET 7+; the only other TFM is netstandard2.0).
  • No public API changes; method stays private static.
  • Regex is thread-safe; no shared mutable state introduced.
  • MatchProperties correctly left out of scope — it operates on PropertyBag and has its own struct-enumerator optimization.

Once #1 and #2 are addressed, this becomes a solid micro-optimization that genuinely lands at zero allocations across all expression shapes.

@EvangelinkAmaury Levé (Evangelink) added the needs/author-feedback Waiting on the original author. label May 25, 2026
@microsoft-github-policy-servicemicrosoft-github-policy-serviceBot removed the needs/author-feedback Waiting on the original author. label May 29, 2026
Sign up for freeto 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

@abdelghani-moussaid@Evangelink
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

perf: reduce TreeNodeFilter allocations via IReadOnlyList and ReadOnlySpan - #8238

Closed
Abdelghani Moussaid (abdelghani-moussaid) wants to merge 6 commits into
microsoft:mainfrom
abdelghani-moussaid:optimize-tree-node-filter
Closed

perf: reduce TreeNodeFilter allocations via IReadOnlyList and ReadOnlySpan#8238
Abdelghani Moussaid (abdelghani-moussaid) wants to merge 6 commits into
microsoft:mainfrom
abdelghani-moussaid:optimize-tree-node-filter

Conversation

@abdelghani-moussaid

@abdelghani-moussaidAbdelghani Moussaid (abdelghani-moussaid) commented May 14, 2026

Copy link
Copy Markdown

Performance optimization for TreeNodeFilter to eliminate allocations during test discovery.

Key Changes:

  • Widened SubExpressions to IReadOnlyList to avoid IEnumerator boxing.
  • Implemented ReadOnlySpan-based recursion for .NET 8.0+ to eliminate O(N) string fragment allocations.
  • Refactored Span path to use foreach loops to satisfy the CS9108 ref-struct capture constraint.

Verification Results:

  • BenchmarkDotNet: 160 B -> 0 B allocations on .NET 8.0. Execution time reduced by ~45%.

Unit Tests: All local TreeNodeFilter tests passed.


Related

…ySpan
- Widen SubExpressions to IReadOnlyList to enable optimized LINQ paths.
- Implement ReadOnlySpan-based matching for .NET 8.0+ to eliminate
string fragment allocations.
- Use procedural loops in Span path to avoid ref-struct capture (CS9108).
@Evangelink

Copy link
Copy Markdown
Member

Code Review

Thanks for the perf work! The intent is solid, but a few things in the implementation don't match the "zero allocations" claim, and the PR description has a couple of inaccuracies worth fixing. Detailed findings below.

🟠 Major

1. foreach (FilterExpression expr in subexprs) over IReadOnlyList<T> allocates an enumerator.

OperatorExpression.SubExpressions is statically typed as IReadOnlyList<FilterExpression> (even though the backing field is FilterExpression[]). IReadOnlyList<T> exposes no struct enumerator, so foreach binds to IEnumerable<T>.GetEnumerator() which returns a heap-allocated enumerator. This happens once per Or/And node, on a hot path — directly contradicting the "0 B" goal.

The existing string overload deliberately avoids this with an indexed for loop:

for(inti=0;i<subexprs.Count;i++){if(MatchFilterPattern(subexprs[i],testNodeFragment,properties)){ ...}}

Please use the same pattern in the Span overload. As a side note, the PR description says the foreach change was needed "to satisfy the CS9108 ref-struct capture constraint" — CS9108 is about lambda/local-function capture of ref struct parameters, and there is no lambda or local function here. The original for loop pattern compiles fine with ReadOnlySpan<char> arguments.

2. subexprs.Single() for the Not case.

caseOperatorExpression{Op:FilterOperator.Not,SubExpressions: var subexprs }:return!MatchFilterPattern(subexprs.Single(),testNodeFragment,properties);

vs. the string overload:

caseOperatorExpression{Op:FilterOperator.Not,SubExpressions:[varsingleSubExpr]}:return!MatchFilterPattern(singleSubExpr,testNodeFragment,properties);

Issues:

  • Unnecessary LINQ call where [var singleSubExpr] (or subexprs[0]) is allocation-free and trivially intent-revealing.
  • Changes the exception contract: InvalidOperationException from LINQ vs. ApplicationStateGuard.Unreachable(). Upstream ParseFilter validation makes this practically unreachable, but having two overloads diverge in exception contract for the same logical case is a future trap.

Please restore the list pattern so the two overloads match.

🟡 Moderate

3. Code duplication. After fixing #1 and #2 the two overloads will be near-identical; consider either:

  • keeping them byte-identical except for the fragment type and adding a short comment cross-referencing each other, or
  • having the string overload simply call .AsSpan() and forward to the Span overload on NET8_0_OR_GREATER, falling back to the original impl only on netstandard2.0.

The second option eliminates the duplication on the TFMs where it actually matters.

4. Tests. The PR adds no tests. With both overloads shipped (string on netstandard2.0, Span on net8+), a parity regression would not be caught on one TFM. Suggest adding a small test that exercises Or (multi-sub), And (multi-sub), Not (1 sub), ValueAndPropertyExpression, and NopExpression so both code paths are validated against the same inputs. An allocation guard on the Span path (AllocatedBytes style) would also lock in the zero-allocation claim once #1 and #2 are fixed.

5. PR description nits.

  • "Widened SubExpressions to IReadOnlyList to avoid IEnumerator boxing" isn't in this diff — OperatorExpression.SubExpressions was already widened in [Efficiency Improver] perf: eliminate LINQ closure allocations in TreeNodeFilter #8035.
  • "Refactored Span path to use foreach loops to satisfy the CS9108 ref-struct capture constraint" — as noted above, CS9108 doesn't apply here.
  • The "~45% time / 160 B → 0 B" benchmark numbers are plausible for a single-fragment ValueExpression input but won't generalize to Or/And/Not trees (which currently regress on allocation, per Initial commit! 🎉 #1). Worth scoping that statement.

✅ Looks good

  • TFM gating with #if NET8_0_OR_GREATER is correct (Regex.IsMatch(ROS<char>) is available on .NET 7+; the only other TFM is netstandard2.0).
  • No public API changes; method stays private static.
  • Regex is thread-safe; no shared mutable state introduced.
  • MatchProperties correctly left out of scope — it operates on PropertyBag and has its own struct-enumerator optimization.

Once #1 and #2 are addressed, this becomes a solid micro-optimization that genuinely lands at zero allocations across all expression shapes.

@EvangelinkAmaury Levé (Evangelink) added the needs/author-feedback Waiting on the original author. label May 25, 2026
@microsoft-github-policy-servicemicrosoft-github-policy-serviceBot removed the needs/author-feedback Waiting on the original author. label May 29, 2026
Sign up for freeto 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

@abdelghani-moussaid@Evangelink
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

perf: reduce TreeNodeFilter allocations via IReadOnlyList and ReadOnlySpan - #8238

Closed
Abdelghani Moussaid (abdelghani-moussaid) wants to merge 6 commits into
microsoft:mainfrom
abdelghani-moussaid:optimize-tree-node-filter
Closed

perf: reduce TreeNodeFilter allocations via IReadOnlyList and ReadOnlySpan#8238
Abdelghani Moussaid (abdelghani-moussaid) wants to merge 6 commits into
microsoft:mainfrom
abdelghani-moussaid:optimize-tree-node-filter

Conversation

@abdelghani-moussaid

@abdelghani-moussaidAbdelghani Moussaid (abdelghani-moussaid) commented May 14, 2026

Copy link
Copy Markdown

Performance optimization for TreeNodeFilter to eliminate allocations during test discovery.

Key Changes:

  • Widened SubExpressions to IReadOnlyList to avoid IEnumerator boxing.
  • Implemented ReadOnlySpan-based recursion for .NET 8.0+ to eliminate O(N) string fragment allocations.
  • Refactored Span path to use foreach loops to satisfy the CS9108 ref-struct capture constraint.

Verification Results:

  • BenchmarkDotNet: 160 B -> 0 B allocations on .NET 8.0. Execution time reduced by ~45%.

Unit Tests: All local TreeNodeFilter tests passed.


Related

…ySpan
- Widen SubExpressions to IReadOnlyList to enable optimized LINQ paths.
- Implement ReadOnlySpan-based matching for .NET 8.0+ to eliminate
string fragment allocations.
- Use procedural loops in Span path to avoid ref-struct capture (CS9108).
@Evangelink

Copy link
Copy Markdown
Member

Code Review

Thanks for the perf work! The intent is solid, but a few things in the implementation don't match the "zero allocations" claim, and the PR description has a couple of inaccuracies worth fixing. Detailed findings below.

🟠 Major

1. foreach (FilterExpression expr in subexprs) over IReadOnlyList<T> allocates an enumerator.

OperatorExpression.SubExpressions is statically typed as IReadOnlyList<FilterExpression> (even though the backing field is FilterExpression[]). IReadOnlyList<T> exposes no struct enumerator, so foreach binds to IEnumerable<T>.GetEnumerator() which returns a heap-allocated enumerator. This happens once per Or/And node, on a hot path — directly contradicting the "0 B" goal.

The existing string overload deliberately avoids this with an indexed for loop:

for(inti=0;i<subexprs.Count;i++){if(MatchFilterPattern(subexprs[i],testNodeFragment,properties)){ ...}}

Please use the same pattern in the Span overload. As a side note, the PR description says the foreach change was needed "to satisfy the CS9108 ref-struct capture constraint" — CS9108 is about lambda/local-function capture of ref struct parameters, and there is no lambda or local function here. The original for loop pattern compiles fine with ReadOnlySpan<char> arguments.

2. subexprs.Single() for the Not case.

caseOperatorExpression{Op:FilterOperator.Not,SubExpressions: var subexprs }:return!MatchFilterPattern(subexprs.Single(),testNodeFragment,properties);

vs. the string overload:

caseOperatorExpression{Op:FilterOperator.Not,SubExpressions:[varsingleSubExpr]}:return!MatchFilterPattern(singleSubExpr,testNodeFragment,properties);

Issues:

  • Unnecessary LINQ call where [var singleSubExpr] (or subexprs[0]) is allocation-free and trivially intent-revealing.
  • Changes the exception contract: InvalidOperationException from LINQ vs. ApplicationStateGuard.Unreachable(). Upstream ParseFilter validation makes this practically unreachable, but having two overloads diverge in exception contract for the same logical case is a future trap.

Please restore the list pattern so the two overloads match.

🟡 Moderate

3. Code duplication. After fixing #1 and #2 the two overloads will be near-identical; consider either:

  • keeping them byte-identical except for the fragment type and adding a short comment cross-referencing each other, or
  • having the string overload simply call .AsSpan() and forward to the Span overload on NET8_0_OR_GREATER, falling back to the original impl only on netstandard2.0.

The second option eliminates the duplication on the TFMs where it actually matters.

4. Tests. The PR adds no tests. With both overloads shipped (string on netstandard2.0, Span on net8+), a parity regression would not be caught on one TFM. Suggest adding a small test that exercises Or (multi-sub), And (multi-sub), Not (1 sub), ValueAndPropertyExpression, and NopExpression so both code paths are validated against the same inputs. An allocation guard on the Span path (AllocatedBytes style) would also lock in the zero-allocation claim once #1 and #2 are fixed.

5. PR description nits.

  • "Widened SubExpressions to IReadOnlyList to avoid IEnumerator boxing" isn't in this diff — OperatorExpression.SubExpressions was already widened in [Efficiency Improver] perf: eliminate LINQ closure allocations in TreeNodeFilter #8035.
  • "Refactored Span path to use foreach loops to satisfy the CS9108 ref-struct capture constraint" — as noted above, CS9108 doesn't apply here.
  • The "~45% time / 160 B → 0 B" benchmark numbers are plausible for a single-fragment ValueExpression input but won't generalize to Or/And/Not trees (which currently regress on allocation, per Initial commit! 🎉 #1). Worth scoping that statement.

✅ Looks good

  • TFM gating with #if NET8_0_OR_GREATER is correct (Regex.IsMatch(ROS<char>) is available on .NET 7+; the only other TFM is netstandard2.0).
  • No public API changes; method stays private static.
  • Regex is thread-safe; no shared mutable state introduced.
  • MatchProperties correctly left out of scope — it operates on PropertyBag and has its own struct-enumerator optimization.

Once #1 and #2 are addressed, this becomes a solid micro-optimization that genuinely lands at zero allocations across all expression shapes.

@EvangelinkAmaury Levé (Evangelink) added the needs/author-feedback Waiting on the original author. label May 25, 2026
@microsoft-github-policy-servicemicrosoft-github-policy-serviceBot removed the needs/author-feedback Waiting on the original author. label May 29, 2026
Sign up for freeto 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

@abdelghani-moussaid@Evangelink
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

perf: reduce TreeNodeFilter allocations via IReadOnlyList and ReadOnlySpan - #8238

Closed
Abdelghani Moussaid (abdelghani-moussaid) wants to merge 6 commits into
microsoft:mainfrom
abdelghani-moussaid:optimize-tree-node-filter
Closed

perf: reduce TreeNodeFilter allocations via IReadOnlyList and ReadOnlySpan#8238
Abdelghani Moussaid (abdelghani-moussaid) wants to merge 6 commits into
microsoft:mainfrom
abdelghani-moussaid:optimize-tree-node-filter

Conversation

@abdelghani-moussaid

@abdelghani-moussaidAbdelghani Moussaid (abdelghani-moussaid) commented May 14, 2026

Copy link
Copy Markdown

Performance optimization for TreeNodeFilter to eliminate allocations during test discovery.

Key Changes:

  • Widened SubExpressions to IReadOnlyList to avoid IEnumerator boxing.
  • Implemented ReadOnlySpan-based recursion for .NET 8.0+ to eliminate O(N) string fragment allocations.
  • Refactored Span path to use foreach loops to satisfy the CS9108 ref-struct capture constraint.

Verification Results:

  • BenchmarkDotNet: 160 B -> 0 B allocations on .NET 8.0. Execution time reduced by ~45%.

Unit Tests: All local TreeNodeFilter tests passed.


Related

…ySpan
- Widen SubExpressions to IReadOnlyList to enable optimized LINQ paths.
- Implement ReadOnlySpan-based matching for .NET 8.0+ to eliminate
string fragment allocations.
- Use procedural loops in Span path to avoid ref-struct capture (CS9108).
@Evangelink

Copy link
Copy Markdown
Member

Code Review

Thanks for the perf work! The intent is solid, but a few things in the implementation don't match the "zero allocations" claim, and the PR description has a couple of inaccuracies worth fixing. Detailed findings below.

🟠 Major

1. foreach (FilterExpression expr in subexprs) over IReadOnlyList<T> allocates an enumerator.

OperatorExpression.SubExpressions is statically typed as IReadOnlyList<FilterExpression> (even though the backing field is FilterExpression[]). IReadOnlyList<T> exposes no struct enumerator, so foreach binds to IEnumerable<T>.GetEnumerator() which returns a heap-allocated enumerator. This happens once per Or/And node, on a hot path — directly contradicting the "0 B" goal.

The existing string overload deliberately avoids this with an indexed for loop:

for(inti=0;i<subexprs.Count;i++){if(MatchFilterPattern(subexprs[i],testNodeFragment,properties)){ ...}}

Please use the same pattern in the Span overload. As a side note, the PR description says the foreach change was needed "to satisfy the CS9108 ref-struct capture constraint" — CS9108 is about lambda/local-function capture of ref struct parameters, and there is no lambda or local function here. The original for loop pattern compiles fine with ReadOnlySpan<char> arguments.

2. subexprs.Single() for the Not case.

caseOperatorExpression{Op:FilterOperator.Not,SubExpressions: var subexprs }:return!MatchFilterPattern(subexprs.Single(),testNodeFragment,properties);

vs. the string overload:

caseOperatorExpression{Op:FilterOperator.Not,SubExpressions:[varsingleSubExpr]}:return!MatchFilterPattern(singleSubExpr,testNodeFragment,properties);

Issues:

  • Unnecessary LINQ call where [var singleSubExpr] (or subexprs[0]) is allocation-free and trivially intent-revealing.
  • Changes the exception contract: InvalidOperationException from LINQ vs. ApplicationStateGuard.Unreachable(). Upstream ParseFilter validation makes this practically unreachable, but having two overloads diverge in exception contract for the same logical case is a future trap.

Please restore the list pattern so the two overloads match.

🟡 Moderate

3. Code duplication. After fixing #1 and #2 the two overloads will be near-identical; consider either:

  • keeping them byte-identical except for the fragment type and adding a short comment cross-referencing each other, or
  • having the string overload simply call .AsSpan() and forward to the Span overload on NET8_0_OR_GREATER, falling back to the original impl only on netstandard2.0.

The second option eliminates the duplication on the TFMs where it actually matters.

4. Tests. The PR adds no tests. With both overloads shipped (string on netstandard2.0, Span on net8+), a parity regression would not be caught on one TFM. Suggest adding a small test that exercises Or (multi-sub), And (multi-sub), Not (1 sub), ValueAndPropertyExpression, and NopExpression so both code paths are validated against the same inputs. An allocation guard on the Span path (AllocatedBytes style) would also lock in the zero-allocation claim once #1 and #2 are fixed.

5. PR description nits.

  • "Widened SubExpressions to IReadOnlyList to avoid IEnumerator boxing" isn't in this diff — OperatorExpression.SubExpressions was already widened in [Efficiency Improver] perf: eliminate LINQ closure allocations in TreeNodeFilter #8035.
  • "Refactored Span path to use foreach loops to satisfy the CS9108 ref-struct capture constraint" — as noted above, CS9108 doesn't apply here.
  • The "~45% time / 160 B → 0 B" benchmark numbers are plausible for a single-fragment ValueExpression input but won't generalize to Or/And/Not trees (which currently regress on allocation, per Initial commit! 🎉 #1). Worth scoping that statement.

✅ Looks good

  • TFM gating with #if NET8_0_OR_GREATER is correct (Regex.IsMatch(ROS<char>) is available on .NET 7+; the only other TFM is netstandard2.0).
  • No public API changes; method stays private static.
  • Regex is thread-safe; no shared mutable state introduced.
  • MatchProperties correctly left out of scope — it operates on PropertyBag and has its own struct-enumerator optimization.

Once #1 and #2 are addressed, this becomes a solid micro-optimization that genuinely lands at zero allocations across all expression shapes.

@EvangelinkAmaury Levé (Evangelink) added the needs/author-feedback Waiting on the original author. label May 25, 2026
@microsoft-github-policy-servicemicrosoft-github-policy-serviceBot removed the needs/author-feedback Waiting on the original author. label May 29, 2026
Sign up for freeto 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

@abdelghani-moussaid@Evangelink
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

perf: reduce TreeNodeFilter allocations via IReadOnlyList and ReadOnlySpan - #8238

Closed
Abdelghani Moussaid (abdelghani-moussaid) wants to merge 6 commits into
microsoft:mainfrom
abdelghani-moussaid:optimize-tree-node-filter
Closed

perf: reduce TreeNodeFilter allocations via IReadOnlyList and ReadOnlySpan#8238
Abdelghani Moussaid (abdelghani-moussaid) wants to merge 6 commits into
microsoft:mainfrom
abdelghani-moussaid:optimize-tree-node-filter

Conversation

@abdelghani-moussaid

@abdelghani-moussaidAbdelghani Moussaid (abdelghani-moussaid) commented May 14, 2026

Copy link
Copy Markdown

Performance optimization for TreeNodeFilter to eliminate allocations during test discovery.

Key Changes:

  • Widened SubExpressions to IReadOnlyList to avoid IEnumerator boxing.
  • Implemented ReadOnlySpan-based recursion for .NET 8.0+ to eliminate O(N) string fragment allocations.
  • Refactored Span path to use foreach loops to satisfy the CS9108 ref-struct capture constraint.

Verification Results:

  • BenchmarkDotNet: 160 B -> 0 B allocations on .NET 8.0. Execution time reduced by ~45%.

Unit Tests: All local TreeNodeFilter tests passed.


Related

…ySpan
- Widen SubExpressions to IReadOnlyList to enable optimized LINQ paths.
- Implement ReadOnlySpan-based matching for .NET 8.0+ to eliminate
string fragment allocations.
- Use procedural loops in Span path to avoid ref-struct capture (CS9108).
@Evangelink

Copy link
Copy Markdown
Member

Code Review

Thanks for the perf work! The intent is solid, but a few things in the implementation don't match the "zero allocations" claim, and the PR description has a couple of inaccuracies worth fixing. Detailed findings below.

🟠 Major

1. foreach (FilterExpression expr in subexprs) over IReadOnlyList<T> allocates an enumerator.

OperatorExpression.SubExpressions is statically typed as IReadOnlyList<FilterExpression> (even though the backing field is FilterExpression[]). IReadOnlyList<T> exposes no struct enumerator, so foreach binds to IEnumerable<T>.GetEnumerator() which returns a heap-allocated enumerator. This happens once per Or/And node, on a hot path — directly contradicting the "0 B" goal.

The existing string overload deliberately avoids this with an indexed for loop:

for(inti=0;i<subexprs.Count;i++){if(MatchFilterPattern(subexprs[i],testNodeFragment,properties)){ ...}}

Please use the same pattern in the Span overload. As a side note, the PR description says the foreach change was needed "to satisfy the CS9108 ref-struct capture constraint" — CS9108 is about lambda/local-function capture of ref struct parameters, and there is no lambda or local function here. The original for loop pattern compiles fine with ReadOnlySpan<char> arguments.

2. subexprs.Single() for the Not case.

caseOperatorExpression{Op:FilterOperator.Not,SubExpressions: var subexprs }:return!MatchFilterPattern(subexprs.Single(),testNodeFragment,properties);

vs. the string overload:

caseOperatorExpression{Op:FilterOperator.Not,SubExpressions:[varsingleSubExpr]}:return!MatchFilterPattern(singleSubExpr,testNodeFragment,properties);

Issues:

  • Unnecessary LINQ call where [var singleSubExpr] (or subexprs[0]) is allocation-free and trivially intent-revealing.
  • Changes the exception contract: InvalidOperationException from LINQ vs. ApplicationStateGuard.Unreachable(). Upstream ParseFilter validation makes this practically unreachable, but having two overloads diverge in exception contract for the same logical case is a future trap.

Please restore the list pattern so the two overloads match.

🟡 Moderate

3. Code duplication. After fixing #1 and #2 the two overloads will be near-identical; consider either:

  • keeping them byte-identical except for the fragment type and adding a short comment cross-referencing each other, or
  • having the string overload simply call .AsSpan() and forward to the Span overload on NET8_0_OR_GREATER, falling back to the original impl only on netstandard2.0.

The second option eliminates the duplication on the TFMs where it actually matters.

4. Tests. The PR adds no tests. With both overloads shipped (string on netstandard2.0, Span on net8+), a parity regression would not be caught on one TFM. Suggest adding a small test that exercises Or (multi-sub), And (multi-sub), Not (1 sub), ValueAndPropertyExpression, and NopExpression so both code paths are validated against the same inputs. An allocation guard on the Span path (AllocatedBytes style) would also lock in the zero-allocation claim once #1 and #2 are fixed.

5. PR description nits.

  • "Widened SubExpressions to IReadOnlyList to avoid IEnumerator boxing" isn't in this diff — OperatorExpression.SubExpressions was already widened in [Efficiency Improver] perf: eliminate LINQ closure allocations in TreeNodeFilter #8035.
  • "Refactored Span path to use foreach loops to satisfy the CS9108 ref-struct capture constraint" — as noted above, CS9108 doesn't apply here.
  • The "~45% time / 160 B → 0 B" benchmark numbers are plausible for a single-fragment ValueExpression input but won't generalize to Or/And/Not trees (which currently regress on allocation, per Initial commit! 🎉 #1). Worth scoping that statement.

✅ Looks good

  • TFM gating with #if NET8_0_OR_GREATER is correct (Regex.IsMatch(ROS<char>) is available on .NET 7+; the only other TFM is netstandard2.0).
  • No public API changes; method stays private static.
  • Regex is thread-safe; no shared mutable state introduced.
  • MatchProperties correctly left out of scope — it operates on PropertyBag and has its own struct-enumerator optimization.

Once #1 and #2 are addressed, this becomes a solid micro-optimization that genuinely lands at zero allocations across all expression shapes.

@EvangelinkAmaury Levé (Evangelink) added the needs/author-feedback Waiting on the original author. label May 25, 2026
@microsoft-github-policy-servicemicrosoft-github-policy-serviceBot removed the needs/author-feedback Waiting on the original author. label May 29, 2026
Sign up for freeto 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

@abdelghani-moussaid@Evangelink
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

perf: reduce TreeNodeFilter allocations via IReadOnlyList and ReadOnlySpan - #8238

Closed
Abdelghani Moussaid (abdelghani-moussaid) wants to merge 6 commits into
microsoft:mainfrom
abdelghani-moussaid:optimize-tree-node-filter
Closed

perf: reduce TreeNodeFilter allocations via IReadOnlyList and ReadOnlySpan#8238
Abdelghani Moussaid (abdelghani-moussaid) wants to merge 6 commits into
microsoft:mainfrom
abdelghani-moussaid:optimize-tree-node-filter

Conversation

@abdelghani-moussaid

@abdelghani-moussaidAbdelghani Moussaid (abdelghani-moussaid) commented May 14, 2026

Copy link
Copy Markdown

Performance optimization for TreeNodeFilter to eliminate allocations during test discovery.

Key Changes:

  • Widened SubExpressions to IReadOnlyList to avoid IEnumerator boxing.
  • Implemented ReadOnlySpan-based recursion for .NET 8.0+ to eliminate O(N) string fragment allocations.
  • Refactored Span path to use foreach loops to satisfy the CS9108 ref-struct capture constraint.

Verification Results:

  • BenchmarkDotNet: 160 B -> 0 B allocations on .NET 8.0. Execution time reduced by ~45%.

Unit Tests: All local TreeNodeFilter tests passed.


Related

…ySpan
- Widen SubExpressions to IReadOnlyList to enable optimized LINQ paths.
- Implement ReadOnlySpan-based matching for .NET 8.0+ to eliminate
string fragment allocations.
- Use procedural loops in Span path to avoid ref-struct capture (CS9108).
@Evangelink

Copy link
Copy Markdown
Member

Code Review

Thanks for the perf work! The intent is solid, but a few things in the implementation don't match the "zero allocations" claim, and the PR description has a couple of inaccuracies worth fixing. Detailed findings below.

🟠 Major

1. foreach (FilterExpression expr in subexprs) over IReadOnlyList<T> allocates an enumerator.

OperatorExpression.SubExpressions is statically typed as IReadOnlyList<FilterExpression> (even though the backing field is FilterExpression[]). IReadOnlyList<T> exposes no struct enumerator, so foreach binds to IEnumerable<T>.GetEnumerator() which returns a heap-allocated enumerator. This happens once per Or/And node, on a hot path — directly contradicting the "0 B" goal.

The existing string overload deliberately avoids this with an indexed for loop:

for(inti=0;i<subexprs.Count;i++){if(MatchFilterPattern(subexprs[i],testNodeFragment,properties)){ ...}}

Please use the same pattern in the Span overload. As a side note, the PR description says the foreach change was needed "to satisfy the CS9108 ref-struct capture constraint" — CS9108 is about lambda/local-function capture of ref struct parameters, and there is no lambda or local function here. The original for loop pattern compiles fine with ReadOnlySpan<char> arguments.

2. subexprs.Single() for the Not case.

caseOperatorExpression{Op:FilterOperator.Not,SubExpressions: var subexprs }:return!MatchFilterPattern(subexprs.Single(),testNodeFragment,properties);

vs. the string overload:

caseOperatorExpression{Op:FilterOperator.Not,SubExpressions:[varsingleSubExpr]}:return!MatchFilterPattern(singleSubExpr,testNodeFragment,properties);

Issues:

  • Unnecessary LINQ call where [var singleSubExpr] (or subexprs[0]) is allocation-free and trivially intent-revealing.
  • Changes the exception contract: InvalidOperationException from LINQ vs. ApplicationStateGuard.Unreachable(). Upstream ParseFilter validation makes this practically unreachable, but having two overloads diverge in exception contract for the same logical case is a future trap.

Please restore the list pattern so the two overloads match.

🟡 Moderate

3. Code duplication. After fixing #1 and #2 the two overloads will be near-identical; consider either:

  • keeping them byte-identical except for the fragment type and adding a short comment cross-referencing each other, or
  • having the string overload simply call .AsSpan() and forward to the Span overload on NET8_0_OR_GREATER, falling back to the original impl only on netstandard2.0.

The second option eliminates the duplication on the TFMs where it actually matters.

4. Tests. The PR adds no tests. With both overloads shipped (string on netstandard2.0, Span on net8+), a parity regression would not be caught on one TFM. Suggest adding a small test that exercises Or (multi-sub), And (multi-sub), Not (1 sub), ValueAndPropertyExpression, and NopExpression so both code paths are validated against the same inputs. An allocation guard on the Span path (AllocatedBytes style) would also lock in the zero-allocation claim once #1 and #2 are fixed.

5. PR description nits.

  • "Widened SubExpressions to IReadOnlyList to avoid IEnumerator boxing" isn't in this diff — OperatorExpression.SubExpressions was already widened in [Efficiency Improver] perf: eliminate LINQ closure allocations in TreeNodeFilter #8035.
  • "Refactored Span path to use foreach loops to satisfy the CS9108 ref-struct capture constraint" — as noted above, CS9108 doesn't apply here.
  • The "~45% time / 160 B → 0 B" benchmark numbers are plausible for a single-fragment ValueExpression input but won't generalize to Or/And/Not trees (which currently regress on allocation, per Initial commit! 🎉 #1). Worth scoping that statement.

✅ Looks good

  • TFM gating with #if NET8_0_OR_GREATER is correct (Regex.IsMatch(ROS<char>) is available on .NET 7+; the only other TFM is netstandard2.0).
  • No public API changes; method stays private static.
  • Regex is thread-safe; no shared mutable state introduced.
  • MatchProperties correctly left out of scope — it operates on PropertyBag and has its own struct-enumerator optimization.

Once #1 and #2 are addressed, this becomes a solid micro-optimization that genuinely lands at zero allocations across all expression shapes.

@EvangelinkAmaury Levé (Evangelink) added the needs/author-feedback Waiting on the original author. label May 25, 2026
@microsoft-github-policy-servicemicrosoft-github-policy-serviceBot removed the needs/author-feedback Waiting on the original author. label May 29, 2026
Sign up for freeto 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

@abdelghani-moussaid@Evangelink
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

perf: reduce TreeNodeFilter allocations via IReadOnlyList and ReadOnlySpan - #8238

Closed
Abdelghani Moussaid (abdelghani-moussaid) wants to merge 6 commits into
microsoft:mainfrom
abdelghani-moussaid:optimize-tree-node-filter
Closed

perf: reduce TreeNodeFilter allocations via IReadOnlyList and ReadOnlySpan#8238
Abdelghani Moussaid (abdelghani-moussaid) wants to merge 6 commits into
microsoft:mainfrom
abdelghani-moussaid:optimize-tree-node-filter

Conversation

@abdelghani-moussaid

@abdelghani-moussaidAbdelghani Moussaid (abdelghani-moussaid) commented May 14, 2026

Copy link
Copy Markdown

Performance optimization for TreeNodeFilter to eliminate allocations during test discovery.

Key Changes:

  • Widened SubExpressions to IReadOnlyList to avoid IEnumerator boxing.
  • Implemented ReadOnlySpan-based recursion for .NET 8.0+ to eliminate O(N) string fragment allocations.
  • Refactored Span path to use foreach loops to satisfy the CS9108 ref-struct capture constraint.

Verification Results:

  • BenchmarkDotNet: 160 B -> 0 B allocations on .NET 8.0. Execution time reduced by ~45%.

Unit Tests: All local TreeNodeFilter tests passed.


Related

…ySpan
- Widen SubExpressions to IReadOnlyList to enable optimized LINQ paths.
- Implement ReadOnlySpan-based matching for .NET 8.0+ to eliminate
string fragment allocations.
- Use procedural loops in Span path to avoid ref-struct capture (CS9108).
@Evangelink

Copy link
Copy Markdown
Member

Code Review

Thanks for the perf work! The intent is solid, but a few things in the implementation don't match the "zero allocations" claim, and the PR description has a couple of inaccuracies worth fixing. Detailed findings below.

🟠 Major

1. foreach (FilterExpression expr in subexprs) over IReadOnlyList<T> allocates an enumerator.

OperatorExpression.SubExpressions is statically typed as IReadOnlyList<FilterExpression> (even though the backing field is FilterExpression[]). IReadOnlyList<T> exposes no struct enumerator, so foreach binds to IEnumerable<T>.GetEnumerator() which returns a heap-allocated enumerator. This happens once per Or/And node, on a hot path — directly contradicting the "0 B" goal.

The existing string overload deliberately avoids this with an indexed for loop:

for(inti=0;i<subexprs.Count;i++){if(MatchFilterPattern(subexprs[i],testNodeFragment,properties)){ ...}}

Please use the same pattern in the Span overload. As a side note, the PR description says the foreach change was needed "to satisfy the CS9108 ref-struct capture constraint" — CS9108 is about lambda/local-function capture of ref struct parameters, and there is no lambda or local function here. The original for loop pattern compiles fine with ReadOnlySpan<char> arguments.

2. subexprs.Single() for the Not case.

caseOperatorExpression{Op:FilterOperator.Not,SubExpressions: var subexprs }:return!MatchFilterPattern(subexprs.Single(),testNodeFragment,properties);

vs. the string overload:

caseOperatorExpression{Op:FilterOperator.Not,SubExpressions:[varsingleSubExpr]}:return!MatchFilterPattern(singleSubExpr,testNodeFragment,properties);

Issues:

  • Unnecessary LINQ call where [var singleSubExpr] (or subexprs[0]) is allocation-free and trivially intent-revealing.
  • Changes the exception contract: InvalidOperationException from LINQ vs. ApplicationStateGuard.Unreachable(). Upstream ParseFilter validation makes this practically unreachable, but having two overloads diverge in exception contract for the same logical case is a future trap.

Please restore the list pattern so the two overloads match.

🟡 Moderate

3. Code duplication. After fixing #1 and #2 the two overloads will be near-identical; consider either:

  • keeping them byte-identical except for the fragment type and adding a short comment cross-referencing each other, or
  • having the string overload simply call .AsSpan() and forward to the Span overload on NET8_0_OR_GREATER, falling back to the original impl only on netstandard2.0.

The second option eliminates the duplication on the TFMs where it actually matters.

4. Tests. The PR adds no tests. With both overloads shipped (string on netstandard2.0, Span on net8+), a parity regression would not be caught on one TFM. Suggest adding a small test that exercises Or (multi-sub), And (multi-sub), Not (1 sub), ValueAndPropertyExpression, and NopExpression so both code paths are validated against the same inputs. An allocation guard on the Span path (AllocatedBytes style) would also lock in the zero-allocation claim once #1 and #2 are fixed.

5. PR description nits.

  • "Widened SubExpressions to IReadOnlyList to avoid IEnumerator boxing" isn't in this diff — OperatorExpression.SubExpressions was already widened in [Efficiency Improver] perf: eliminate LINQ closure allocations in TreeNodeFilter #8035.
  • "Refactored Span path to use foreach loops to satisfy the CS9108 ref-struct capture constraint" — as noted above, CS9108 doesn't apply here.
  • The "~45% time / 160 B → 0 B" benchmark numbers are plausible for a single-fragment ValueExpression input but won't generalize to Or/And/Not trees (which currently regress on allocation, per Initial commit! 🎉 #1). Worth scoping that statement.

✅ Looks good

  • TFM gating with #if NET8_0_OR_GREATER is correct (Regex.IsMatch(ROS<char>) is available on .NET 7+; the only other TFM is netstandard2.0).
  • No public API changes; method stays private static.
  • Regex is thread-safe; no shared mutable state introduced.
  • MatchProperties correctly left out of scope — it operates on PropertyBag and has its own struct-enumerator optimization.

Once #1 and #2 are addressed, this becomes a solid micro-optimization that genuinely lands at zero allocations across all expression shapes.

@EvangelinkAmaury Levé (Evangelink) added the needs/author-feedback Waiting on the original author. label May 25, 2026
@microsoft-github-policy-servicemicrosoft-github-policy-serviceBot removed the needs/author-feedback Waiting on the original author. label May 29, 2026
Sign up for freeto 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

@abdelghani-moussaid@Evangelink