Skip to content

Perf: Eliminate LINQ allocations in BoxAndWhiskerSeries.GenerateSegments#370

Merged
PaulAndersonS merged 2 commits into
mainfrom
paulandersons/perf-daily-performance-improvements
May 27, 2026
Merged

Perf: Eliminate LINQ allocations in BoxAndWhiskerSeries.GenerateSegments#370
PaulAndersonS merged 2 commits into
mainfrom
paulandersons/perf-daily-performance-improvements

Conversation

@PaulAndersonS
Copy link
Copy Markdown
Collaborator

Root Cause of the Issue

The GenerateSegments method in BoxAndWhiskerSeries runs a loop over every data point, and each iteration performed several unnecessary LINQ allocations:

  1. .Where(x => !double.IsNaN(x)).ToArray() — allocates a closure, an intermediate IEnumerable enumerator, and a final array on every iteration
  2. .Average() — LINQ extension that enumerates the array again with overhead
  3. .Min() / .Max() — iterates the entire sorted array when the result is simply yList[0] / yList[^1]

Description of Change

  • Replace LINQ filter with FilterNaNValues helper: A simple two-pass loop (count then copy) that avoids closure/enumerator allocations entirely.
  • Replace yList.Average() with manual sum loop: Eliminates LINQ overhead for a trivial arithmetic operation.
  • Replace yList.Min()/yList.Max() with direct index access: Since the array is already sorted by Array.Sort(yList), the min and max are at known positions.

These changes reduce GC pressure in a hot path that executes once per data point during chart segment generation.

Issues Fixed

Performance improvement — no associated issue.

Screenshots

N/A — no visual changes, behavior is identical.

Replace per-iteration LINQ .Where().ToArray() with a manual loop-based
FilterNaNValues helper that avoids closure and enumerator allocations.
Replace .Average() with a manual sum loop to eliminate LINQ overhead.
Replace .Min()/.Max() on an already-sorted array with direct index
access (yList[0] and yList[^1]).

These changes reduce GC pressure in the hot path that runs once per
data point during chart segment generation.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

var result = new double[count];
int index = 0;
for (int i = 0; i < source.Count; i++)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why does FilterNaNValues(IList source) use two for loops (one to count valid values, then one to copy them) so that double.IsNaN is checked twice for each element, instead of using a single-pass approach?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot Check this feedback

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The two-pass version is intentional because this helper returns a double[] with exact length while avoiding LINQ/list allocations. In a true single-pass approach, we’d either use List<double> (extra object + ToArray copy) or allocate source.Count and then resize/copy. For this hot path, avoiding those extra allocations/copies was prioritized over the extra IsNaN check.

Copy link
Copy Markdown
Collaborator

@SaiyathAliFathima SaiyathAliFathima left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We have ensured the changes and working fine

@PaulAndersonS PaulAndersonS merged commit 7ec3af9 into main May 27, 2026
2 checks passed
@PaulAndersonS PaulAndersonS added this to the v1.0.11 milestone May 27, 2026
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.

3 participants