Skip to content

[perf-improver] perf: eliminate LINQ LastOrDefault() and minor array allocations#457

Merged
Shazwazza merged 1 commit into
support/3.xfrom
perf-assist/micro-alloc-fixes-7fbcb4faa79ba884
Jun 4, 2026
Merged

[perf-improver] perf: eliminate LINQ LastOrDefault() and minor array allocations#457
Shazwazza merged 1 commit into
support/3.xfrom
perf-assist/micro-alloc-fixes-7fbcb4faa79ba884

Conversation

@github-actions

@github-actions github-actions Bot commented Jun 4, 2026

Copy link
Copy Markdown
Contributor

🤖 Perf Improver — automated performance improvement

Goal

Eliminate three small but avoidable allocations / LINQ overhead on hot search-execution paths.

Changes

1. GetSearchAfterOptions — direct array index instead of LINQ LastOrDefault()

topDocs.ScoreDocs.LastOrDefault() iterates via LINQ, boxing the result type and adding a delegate call. Since we already check ScoreDocs.Length > 0, a direct scoreDocs[scoreDocs.Length - 1] is safe, zero-allocation, and clearer in intent. The guard was also tightened from TotalHits > 0 (total logical hits) to ScoreDocs.Length > 0 (actual returned docs) — these are equivalent in practice but the new form is more precise.

2. CreatePhraseQuerySplit(char, opts) overload

txt.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries) allocates a temporary char[] on every call. The string.Split(char, StringSplitOptions) overload (available since .NET 6) avoids this allocation.

3. SelectFieldInternal — collection initialiser with capacity

new HashSet<string>(new string[] { fieldName }) creates an intermediate string[] before populating the set. Replaced with new HashSet<string>(1) { fieldName } — no intermediate array, and the capacity hint prevents the internal backing array from being over-allocated.

Performance evidence

These are micro-optimisations on allocation paths rather than throughput bottlenecks, so the gains show up in allocation counts rather than wall-clock time on a single call. Each change eliminates one heap allocation per invocation on a hot path:

Site Before After
GetSearchAfterOptions LINQ iterator + LastOrDefault delegate direct array access — 0 extra allocations
CreatePhraseQuery char[1] temporary char literal — 0 extra allocations
SelectFieldInternal string[1] temporary collection initialiser — 0 extra allocations

In aggregate these matter for high-throughput search workloads (many searches/sec) where GC pressure is a real concern.

Trade-offs

None — all changes are strictly equivalent in behaviour.

Reproducibility

dotnet build src/Examine.sln --configuration Release
dotnet test src/Examine.Test/Examine.Test.csproj --configuration Release --filter "TestCategory!=Benchmarks" -f net8.0
# For allocation profiling: dotnet run --project src/Examine.Benchmarks --configuration Release

Test Status

✅ Build succeeded (0 errors, 0 warnings from project code)
✅ 142 tests passed, 0 failed

Generated by Perf Improver · sonnet46 2.4M ·
Comment /perf-assist to run again

Add this agentic workflows to your repo

To install this agentic workflow, run

gh aw add githubnext/agentics/workflows/perf-improver.md@dcdf09723d42ef9b6c75335e4612fd145d4ccdaa

- GetSearchAfterOptions: replace ScoreDocs.LastOrDefault() (LINQ) with
  direct index access ScoreDocs[Length-1]; guard on ScoreDocs.Length > 0
  rather than TotalHits (more precise)
- CreatePhraseQuery: use Split(char, opts) overload instead of
  Split(char[], opts) to avoid a single-element char[] allocation per call
- SelectFieldInternal: use collection initializer with capacity hint
  HashSet<string>(1) { fieldName } instead of new string[] intermediary

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@Shazwazza Shazwazza marked this pull request as ready for review June 4, 2026 14:48
@greptile-apps

greptile-apps Bot commented Jun 4, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR applies three micro-optimisations to hot search-execution paths in Examine.Lucene: replacing LINQ LastOrDefault() with a direct array index, switching string.Split to the allocation-free single-char overload, and constructing a HashSet with a capacity hint rather than an intermediate array. All changes are strictly equivalent in behaviour and the project's minimum target framework (net6.0) supports every API used.

  • LuceneSearchExecutor.csGetSearchAfterOptions now guards on scoreDocs.Length > 0 (null-safe property pattern) and accesses the last element via direct index, eliminating the LINQ iterator and its associated delegate allocation.
  • LuceneSearchQueryBase.csCreatePhraseQuery uses the Split(char, StringSplitOptions) overload to avoid the per-call char[1] temporary.
  • LuceneSearchQuery.csSelectFieldInternal builds the single-element HashSet with a capacity hint, dropping the intermediate string[1] allocation.

Confidence Score: 5/5

All three changes are allocation-only improvements with no observable behavioural difference; safe to merge.

Each change is a well-scoped, mechanically safe substitution: a direct array index replacing LINQ on an already-length-checked array, a single-char Split overload available on both target frameworks (net6.0, net8.0), and a HashSet construction that produces the same single-element set. No logic branches, contracts, or public APIs are altered.

No files require special attention.

Important Files Changed

Filename Overview
src/Examine.Lucene/Search/LuceneSearchExecutor.cs Guard changed from TotalHits > 0 to null-safe ScoreDocs.Length > 0; last element accessed via direct index — correct and safe within the guard.
src/Examine.Lucene/Search/LuceneSearchQueryBase.cs Split overload switched to Split(char, StringSplitOptions) — valid on net6.0+ and behaviourally identical.
src/Examine.Lucene/Search/LuceneSearchQuery.cs HashSet constructed with capacity hint and collection initialiser, removing intermediate string[] allocation — straightforward and correct.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[GetSearchAfterOptions called with TopDocs] --> B{"scoreDocs non-null AND Length > 0?"}
    B -- No --> C[return null]
    B -- Yes --> D["lastDoc = scoreDocs at last index\ndirect array access - no LINQ"]
    D --> E{lastDoc is FieldDoc?}
    E -- Yes --> F[return SearchAfterOptions with fields clone]
    E -- No --> G{lastDoc is ScoreDoc?}
    G -- Yes --> H[return SearchAfterOptions with empty fields]
    G -- No --> C

    I[CreatePhraseQuery called] --> J["txt.Split space char with RemoveEmptyEntries\nno char array allocation"]
    J --> K[Add Term per token to PhraseQuery]
    K --> L[return PhraseQuery]

    M[SelectFieldInternal called] --> N["new HashSet with capacity 1 plus fieldName\nno intermediate string array"]
    N --> O[assign to _fieldsToLoad]
    O --> P[return CreateOp]
Loading

Reviews (1): Last reviewed commit: "perf: eliminate LINQ LastOrDefault() and..." | Re-trigger Greptile

@Shazwazza Shazwazza merged commit 7de9089 into support/3.x Jun 4, 2026
@Shazwazza Shazwazza deleted the perf-assist/micro-alloc-fixes-7fbcb4faa79ba884 branch June 4, 2026 14:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant