Skip to content

Sum into separate accumulators when averaging a span of Int32 - #134403

Closed
tahakocal wants to merge 1 commit into
dotnet:mainfrom
tahakocal:perf/linq-average-accumulators
Closed

tahakocal wants to merge 1 commit into
dotnet:mainfrom
tahakocal:perf/linq-average-accumulators

Conversation

@tahakocal

Copy link
Copy Markdown

Average(IEnumerable<int>) widens each vector of Int32 values and adds both halves into the same accumulator:

Vector<long> sums = default;
do
{
    Vector.Widen(new Vector<int>(span.Slice(i)), out Vector<long> low, out Vector<long> high);
    sums += low;
    sums += high;        // waits for the add above, and for the previous iteration
    i += Vector<int>.Count;
}
while (i <= span.Length - Vector<int>.Count);

Both adds in an iteration, and the adds of consecutive iterations, form one dependency chain, so the loop runs at the latency of the add rather than at its throughput.

Change

Each half gets its own accumulator and two vectors are processed per iteration, giving four accumulators that are combined once the loop ends. A second loop handles a leftover vector, and the scalar tail is unchanged.

Widened Int32 values sum exactly in Int64 — no rounding, and no overflow, since a span of at most int.MaxValue elements each below 2³¹ cannot exceed 2⁶² — and integer addition is associative, so grouping the partial sums differently cannot change the result. This is why the same change is not appropriate for the floating-point overloads.

Measurements

Standalone harness (I could not build the runtime on this machine), Apple M-series arm64 where Vector<int>.Count is 4. Minimum of 7 rounds, ns per call:

length before two accumulators four accumulators
128 16.1 16.5 (0.98x) 11.9 (1.35x)
1,024 193.7 127.1 (1.52x) 97.1 (2.00x)
8,192 1,805.8 981.5 (1.84x) 742.8 (2.43x)
65,536 14,974.1 7,885.4 (1.90x) 5,836.2 (2.57x)
1,000,000 229,162 120,984 (1.89x) 88,260 (2.60x)

I measured the two-accumulator variant as well, which is the smaller change, but four is consistently better and is what the PR implements. On hardware with a wider Vector<T> the crossover length will be proportionally higher; the short-span path is unchanged and 128 elements already showed no regression here.

Correctness

Differential against a scalar oracle in the harness: 60,000 cases, lengths 1–200, values seeded with int.MinValue, int.MaxValue and zero alongside random ones — 0 mismatches. That covers lengths below one vector, exact multiples of one and two vectors, and every remainder.

I have not run the System.Linq test suite locally for the reason above, so CI is the first full validation.

Average(IEnumerable<int>) widens each vector of Int32 values and adds both
halves into a single Vector<long>, so the two adds of an iteration and the
adds of consecutive iterations all wait for each other, leaving the loop bound
by add latency rather than throughput.

Give each half its own accumulator and process two vectors per iteration, for
four accumulators in total, combined once the loop ends. Widened Int32 values
sum exactly in Int64 and integer addition is associative, so the result is
unchanged regardless of how the partial sums are grouped.

Measured on arm64 with a 128-bit Vector<int>: 2.0x at 1K elements, 2.4x at 8K
and 2.6x at 1M, with no regression at 128 elements.
@dotnet-policy-service dotnet-policy-service Bot added the community-contribution Indicates that the PR has been added by a community member label Sep 22, 2026
@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 3 pipeline(s).
13 pipeline(s) were filtered out due to trigger conditions.
There may be pipelines that require an authorized user to comment /azp run to run.

@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Tagging subscribers to this area: @dotnet/area-system-linq
See info in area-owners.md if you want to be subscribed.

@tahakocal

Copy link
Copy Markdown
Author

The description says I could not run the System.Linq suite locally; that is no longer true. Built the runtime and ran System.Linq.Tests on osx-arm64 Release from this branch: 52,260 total, 0 errors, 0 failed, 8 skipped.

@tahakocal

Copy link
Copy Markdown
Author

Measured end to end through the public API rather than through an extracted loop, since that is how the runtime team runs benchmarks and it turned out to matter for my other PRs.

Method: a console app calling Enumerable.Average(int[]), run with artifacts/bin/testhost/net11.0-osx-Release-arm64/dotnet so it binds to the locally built System.Linq, DOTNET_TieredCompilation=0, minimum of 9 rounds after 300 warmup calls, osx-arm64. Baseline is a clean build of main measured the same way on the same machine.

length main this branch
1,024 226.9 ns 92.9 ns 2.44x
8,192 1,991.7 ns 690.7 ns 2.88x
1,000,000 256,590 ns 87,106 ns 2.95x

In the same run the APIs this branch does not touch — Min/Max over int[], long[], float[] — stay within a percent of the baseline, which is the control for the measurement.

Tests with that build: System.Linq.Tests 52,260 total, 0 failed, 8 skipped.

@tahakocal tahakocal closed this Sep 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area-System.Linq community-contribution Indicates that the PR has been added by a community member

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant