Skip to content

SIMD vectorization of Array.sum<int>, etc - #18509

Open
Thorium wants to merge 14 commits into
dotnet:mainfrom
Thorium:vectorization
Open

SIMD vectorization of Array.sum<int>, etc#18509
Thorium wants to merge 14 commits into
dotnet:mainfrom
Thorium:vectorization

Conversation

@Thorium

@ThoriumThorium commented Apr 26, 2025

Copy link
Copy Markdown
Contributor

Description

Specific overloads (float, float32, int, int64) of Seq.sum, Seq.average, Array.sum and Array.average to take advantage of vectorization in System.Linq.Enumerable module.

This is potentially a naive first try to solve #16230 by the spirit of @T-Gro comment #16230 (comment)

Checklist

  • Test cases added
  • Performance benchmarks added in case of performance changes
  • Release notes entry updated:

    Please make sure to add an entry with short succinct description of the change as well as link to this pull request to the respective release notes file, if applicable.

…ge, Array.sum and Array.average to take advantage of vectorization in System.Linq.Enumerable module.
@github-actions

github-actionsBot commented Apr 26, 2025

Copy link
Copy Markdown
Contributor

❗ Release notes required


✅ Found changes and release notes in following paths:

Change pathRelease notes pathDescription
src/FSharp.Coredocs/release-notes/.FSharp.Core/10.0.100.md

Comment threadsrc/FSharp.Core/array.fs Outdated
Comment on lines +1591 to +1605
[<CompiledName("Sum")>]
let inline sumFloat (array: float array) : float =
System.Linq.Enumerable.Sum array

[<CompiledName("Sum")>]
let inline sumFloat32 (array: float32 array) : float32 =
System.Linq.Enumerable.Sum array

[<CompiledName("Sum")>]
let inline sumInt (array: int array) : int =
System.Linq.Enumerable.Sum array

[<CompiledName("Sum")>]
let inline sumInt64 (array: int64 array) : int64 =
System.Linq.Enumerable.Sum array

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.

I would think that this would be a reasonable place to use static optimization syntax to specify which types should delegate to the LINQ method and which to the existing code, e.g.,

let inlinesum(array:^T array):^T =
existingSumCode array
when^T : int = System.Linq.Enumerable.Sum array
when^T : int64 = System.Linq.Enumerable.Sum array
…

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Sure. I expect "static optimization conditionals" are a compile-time thing and not runtime? Because I can't check easily with sharplab.io, it says "error FS0817: Static optimization conditionals are only for use within the F# library"

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.

Yes, the appropriate implementation would be chosen at compile-time once the type parameter was resolved.

You could add some IL tests under https://github.com/dotnet/fsharp/tree/main/tests/FSharp.Compiler.ComponentTests/EmittedIL if you wanted.

@T-Gro

Copy link
Copy Markdown
Member

It looks like the error case is now different for average over an empty collection.
If it is the same exception type, and only a different message, we could justify the break.

(we should not evaluate a seq before calling into Average, since this would be breaking in a different way)

@Thorium

Thorium commented Apr 26, 2025

Copy link
Copy Markdown
ContributorAuthor

I did some initial tests to see if this makes sense at all or not. I used the current benchmarks\CompiledCodeBenchmarks\MicroPerf\MicroPerf.fsproj with adding following tests:

[<Benchmark>]memberx.ArraySum()=// Array sum
array
|> Array.sum |> ignore
[<Benchmark>]memberx.ArrayAverage()=// There has an extra map, because average needs float
array
|> Array.map float
|> Array.average |> ignore
[<Benchmark>]memberx.ArraySeqSum()=// Seq sum by using array as base data
array
|> Seq.sum |> ignore
[<Benchmark>]memberx.ListSeqSum()=// Seq sum by using list as base data
list
|> Seq.sum |> ignore

And here are the results with current main:


BenchmarkDotNet v0.13.10, Windows 11 (10.0.26100.3915)
13th Gen Intel Core i9-13900H, 1 CPU, 20 logical and 14 physical cores
.NET SDK 9.0.203
[Host] : .NET 9.0.4 (9.0.425.16305), X64 RyuJIT AVX2 DEBUG
Job-ZVNJYC : .NET 9.0.4 (9.0.425.16305), X64 RyuJIT AVX2
Job-NFLFBL : .NET 9.0.4 (9.0.425.16305), X64 RyuJIT AVX2
Job-ABSOZP : .NET 9.0.4 (9.0.425.16305), X64 RyuJIT AVX2
Server=True 
MethodJobArgumentsIterationCountLaunchCountWarmupCountLengthMeanErrorStdDevRatioRatioSDGen0AllocatedAlloc Ratio
ArraySumJob-ZVNJYC/p:BUILDING_USING_DOTNET=trueDefaultDefaultDefault1000206.4 ns1.13 ns1.06 ns1.000.00--NA
ArraySumJob-NFLFBL/p:BUILDING_USING_DOTNET=trueDefaultDefaultDefault1000205.9 ns0.50 ns0.39 ns1.000.01--NA
ArraySumJob-ABSOZPDefault2211000207.8 ns9.28 ns1.44 ns1.010.01--NA
ArrayAverageJob-ZVNJYC/p:BUILDING_USING_DOTNET=trueDefaultDefaultDefault10001,538.6 ns35.62 ns105.04 ns1.000.000.17368024 B1.00
ArrayAverageJob-NFLFBL/p:BUILDING_USING_DOTNET=trueDefaultDefaultDefault10001,471.2 ns29.23 ns53.45 ns0.940.070.17368024 B1.00
ArrayAverageJob-ABSOZPDefault22110001,495.0 ns759.33 ns117.51 ns0.920.070.17368024 B1.00
ArraySeqSumJob-ZVNJYC/p:BUILDING_USING_DOTNET=trueDefaultDefaultDefault1000500.6 ns3.69 ns3.46 ns1.000.00-32 B1.00
ArraySeqSumJob-NFLFBL/p:BUILDING_USING_DOTNET=trueDefaultDefaultDefault1000496.4 ns1.80 ns1.40 ns0.990.01-32 B1.00
ArraySeqSumJob-ABSOZPDefault2211000498.9 ns34.99 ns5.41 ns1.000.01-32 B1.00
ListSeqSumJob-ZVNJYC/p:BUILDING_USING_DOTNET=trueDefaultDefaultDefault10001,234.6 ns3.12 ns2.91 ns1.000.00-40 B1.00
ListSeqSumJob-NFLFBL/p:BUILDING_USING_DOTNET=trueDefaultDefaultDefault10001,243.7 ns19.42 ns17.21 ns1.010.01-40 B1.00
ListSeqSumJob-ABSOZPDefault22110001,251.6 ns55.34 ns8.56 ns1.010.010.001940 B1.00
ArraySumJob-ZVNJYC/p:BUILDING_USING_DOTNET=trueDefaultDefaultDefault100001,949.4 ns8.32 ns6.95 ns1.000.00--NA
ArraySumJob-NFLFBL/p:BUILDING_USING_DOTNET=trueDefaultDefaultDefault100001,956.5 ns24.32 ns22.75 ns1.000.01--NA
ArraySumJob-ABSOZPDefault221100001,966.3 ns128.42 ns19.87 ns1.010.01--NA
ArrayAverageJob-ZVNJYC/p:BUILDING_USING_DOTNET=trueDefaultDefaultDefault1000013,892.3 ns415.41 ns1,211.78 ns1.000.001.861680024 B1.00
ArrayAverageJob-NFLFBL/p:BUILDING_USING_DOTNET=trueDefaultDefaultDefault1000015,129.6 ns242.87 ns227.18 ns1.090.131.937980024 B1.00
ArrayAverageJob-ABSOZPDefault2211000012,991.8 ns8,355.23 ns1,292.98 ns1.000.261.861680024 B1.00
ArraySeqSumJob-ZVNJYC/p:BUILDING_USING_DOTNET=trueDefaultDefaultDefault100004,889.1 ns48.55 ns43.04 ns1.000.00-32 B1.00
ArraySeqSumJob-NFLFBL/p:BUILDING_USING_DOTNET=trueDefaultDefaultDefault100004,828.3 ns45.71 ns38.17 ns0.990.01-32 B1.00
ArraySeqSumJob-ABSOZPDefault221100004,833.0 ns122.61 ns18.97 ns0.980.00-32 B1.00
ListSeqSumJob-ZVNJYC/p:BUILDING_USING_DOTNET=trueDefaultDefaultDefault1000014,042.3 ns280.58 ns483.98 ns1.000.00-40 B1.00
ListSeqSumJob-NFLFBL/p:BUILDING_USING_DOTNET=trueDefaultDefaultDefault1000013,767.9 ns240.03 ns420.40 ns0.980.04-40 B1.00
ListSeqSumJob-ABSOZPDefault2211000013,537.7 ns4,046.83 ns626.25 ns0.950.07-40 B1.00

Here are the results with this PR:

MethodJobArgumentsIterationCountLaunchCountWarmupCountLengthMeanErrorStdDevMedianRatioRatioSDGen0AllocatedAlloc Ratio
ArraySumJob-WQFBJH/p:BUILDING_USING_DOTNET=trueDefaultDefaultDefault100060.66 ns0.489 ns0.434 ns60.59 ns1.000.00--NA
ArraySumJob-ZZOCDZ/p:BUILDING_USING_DOTNET=trueDefaultDefaultDefault1000207.89 ns3.431 ns3.209 ns206.96 ns3.430.05--NA
ArraySumJob-BMQAWTDefault221100060.18 ns3.505 ns0.542 ns60.19 ns0.990.02--NA
ArrayAverageJob-WQFBJH/p:BUILDING_USING_DOTNET=trueDefaultDefaultDefault10001,693.99 ns29.205 ns28.683 ns1,694.44 ns1.000.000.17368024 B1.00
ArrayAverageJob-ZZOCDZ/p:BUILDING_USING_DOTNET=trueDefaultDefaultDefault10001,443.74 ns47.701 ns140.649 ns1,446.84 ns0.870.040.17368024 B1.00
ArrayAverageJob-BMQAWTDefault22110001,486.78 ns226.790 ns35.096 ns1,493.10 ns0.890.020.17368024 B1.00
ArraySeqSumJob-WQFBJH/p:BUILDING_USING_DOTNET=trueDefaultDefaultDefault100060.23 ns0.426 ns0.378 ns60.11 ns1.000.00--NA
ArraySeqSumJob-ZZOCDZ/p:BUILDING_USING_DOTNET=trueDefaultDefaultDefault1000497.31 ns4.195 ns3.719 ns496.69 ns8.260.09-32 BNA
ArraySeqSumJob-BMQAWTDefault221100061.30 ns1.343 ns0.208 ns61.31 ns1.020.00--NA
ListSeqSumJob-WQFBJH/p:BUILDING_USING_DOTNET=trueDefaultDefaultDefault10001,154.49 ns9.687 ns9.061 ns1,155.70 ns1.000.00-40 B1.00
ListSeqSumJob-ZZOCDZ/p:BUILDING_USING_DOTNET=trueDefaultDefaultDefault10001,228.62 ns5.630 ns4.395 ns1,228.27 ns1.060.01-40 B1.00
ListSeqSumJob-BMQAWTDefault22110001,164.41 ns66.398 ns10.275 ns1,159.92 ns1.010.020.001940 B1.00
ArraySumJob-WQFBJH/p:BUILDING_USING_DOTNET=trueDefaultDefaultDefault10000546.81 ns1.656 ns1.549 ns547.20 ns1.000.00--NA
ArraySumJob-ZZOCDZ/p:BUILDING_USING_DOTNET=trueDefaultDefaultDefault100001,961.44 ns25.704 ns24.044 ns1,961.63 ns3.590.05--NA
ArraySumJob-BMQAWTDefault22110000541.97 ns33.331 ns5.158 ns541.34 ns0.990.01--NA
ArrayAverageJob-WQFBJH/p:BUILDING_USING_DOTNET=trueDefaultDefaultDefault1000016,012.49 ns316.673 ns721.226 ns16,238.87 ns1.000.001.846380024 B1.00
ArrayAverageJob-ZZOCDZ/p:BUILDING_USING_DOTNET=trueDefaultDefaultDefault1000013,651.54 ns310.627 ns865.903 ns13,843.05 ns0.840.081.876880024 B1.00
ArrayAverageJob-BMQAWTDefault2211000017,271.21 ns3,620.263 ns560.240 ns17,365.04 ns1.100.041.892180024 B1.00
ArraySeqSumJob-WQFBJH/p:BUILDING_USING_DOTNET=trueDefaultDefaultDefault10000554.40 ns3.815 ns3.569 ns554.73 ns1.000.00--NA
ArraySeqSumJob-ZZOCDZ/p:BUILDING_USING_DOTNET=trueDefaultDefaultDefault100004,813.90 ns39.532 ns33.011 ns4,801.26 ns8.690.09-32 BNA
ArraySeqSumJob-BMQAWTDefault22110000551.93 ns12.280 ns1.900 ns552.36 ns1.000.00--NA
ListSeqSumJob-WQFBJH/p:BUILDING_USING_DOTNET=trueDefaultDefaultDefault1000014,523.24 ns768.122 ns2,240.648 ns13,893.55 ns1.000.00-40 B1.00
ListSeqSumJob-ZZOCDZ/p:BUILDING_USING_DOTNET=trueDefaultDefaultDefault1000015,181.03 ns458.748 ns1,338.189 ns14,985.06 ns1.070.18-40 B1.00
ListSeqSumJob-BMQAWTDefault2211000013,985.62 ns10,680.237 ns1,652.779 ns13,530.39 ns1.000.17-40 B1.00

Edit: I used Net 9 but the FSharp.Core.dll netstandard2.0 version in both, which was probably a mistake because Spans are truely efficient on netstandard2.1 only (?)

@Thorium

Thorium commented Apr 27, 2025

Copy link
Copy Markdown
ContributorAuthor

The IL for Enumerable is very complicated, and is heavily relying on the JIT to simplify it. In Release mode, I observe Enumerable to take half the time; in Debug mode Enumerable takes about 4x the time.

Hmm, I'm thinking if default FSI is compiled with debug mode and people use F# as a scripting language, then performance optimizations like this could be marked as #if !DEBUG. However, it would be even weirder if an error message were different between debug and release.

I did run the same performance tests with FSharp.Core.dll netstandard2.1 version

Main branch:

MethodJobArgumentsIterationCountLaunchCountWarmupCountLengthMeanErrorStdDevRatioRatioSDGen0AllocatedAlloc Ratio
ArraySumJob-WCJSKA/p:BUILDING_USING_DOTNET=trueDefaultDefaultDefault1000207.2 ns2.34 ns2.19 ns1.000.00--NA
ArraySumJob-ZZTGPQ/p:BUILDING_USING_DOTNET=trueDefaultDefaultDefault1000204.2 ns3.47 ns2.90 ns0.990.02--NA
ArraySumJob-EYKYAGDefault2211000206.8 ns22.17 ns3.43 ns1.010.02--NA
ArraySeqSumJob-WCJSKA/p:BUILDING_USING_DOTNET=trueDefaultDefaultDefault1000490.6 ns2.15 ns1.80 ns1.000.00-32 B1.00
ArraySeqSumJob-ZZTGPQ/p:BUILDING_USING_DOTNET=trueDefaultDefaultDefault1000486.8 ns2.41 ns2.13 ns0.990.01-32 B1.00
ArraySeqSumJob-EYKYAGDefault2211000488.8 ns74.19 ns11.48 ns1.000.02-32 B1.00
ListSeqSumJob-WCJSKA/p:BUILDING_USING_DOTNET=trueDefaultDefaultDefault10001,214.5 ns2.83 ns2.51 ns1.000.00-40 B1.00
ListSeqSumJob-ZZTGPQ/p:BUILDING_USING_DOTNET=trueDefaultDefaultDefault10001,241.1 ns19.15 ns17.91 ns1.020.01-40 B1.00
ListSeqSumJob-EYKYAGDefault22110001,216.6 ns34.26 ns5.30 ns1.000.000.001940 B1.00
ArraySumJob-WCJSKA/p:BUILDING_USING_DOTNET=trueDefaultDefaultDefault100001,905.3 ns8.41 ns7.02 ns1.000.00--NA
ArraySumJob-ZZTGPQ/p:BUILDING_USING_DOTNET=trueDefaultDefaultDefault100001,924.9 ns12.14 ns11.36 ns1.010.01--NA
ArraySumJob-EYKYAGDefault221100001,933.3 ns95.32 ns14.75 ns1.010.01--NA
ArraySeqSumJob-WCJSKA/p:BUILDING_USING_DOTNET=trueDefaultDefaultDefault100004,710.8 ns18.93 ns15.80 ns1.000.00-32 B1.00
ArraySeqSumJob-ZZTGPQ/p:BUILDING_USING_DOTNET=trueDefaultDefaultDefault100004,760.8 ns17.52 ns15.53 ns1.010.00-32 B1.00
ArraySeqSumJob-EYKYAGDefault221100004,776.5 ns345.62 ns53.49 ns1.010.01-32 B1.00
ListSeqSumJob-WCJSKA/p:BUILDING_USING_DOTNET=trueDefaultDefaultDefault1000014,616.1 ns355.10 ns1,030.22 ns1.000.00-40 B1.00
ListSeqSumJob-ZZTGPQ/p:BUILDING_USING_DOTNET=trueDefaultDefaultDefault1000013,809.1 ns270.69 ns300.87 ns0.940.08-40 B1.00
ListSeqSumJob-EYKYAGDefault2211000015,396.3 ns3,747.35 ns579.91 ns1.050.14-40 B1.00

This PR

MethodJobArgumentsIterationCountLaunchCountWarmupCountLengthMeanErrorStdDevMedianRatioRatioSDGen0AllocatedAlloc Ratio
ArraySumJob-YRWBVV/p:BUILDING_USING_DOTNET=trueDefaultDefaultDefault100059.19 ns0.550 ns0.514 ns59.01 ns1.000.00--NA
ArraySumJob-YQEOCP/p:BUILDING_USING_DOTNET=trueDefaultDefaultDefault1000206.10 ns0.720 ns0.674 ns206.08 ns3.480.03--NA
ArraySumJob-SEOIASDefault221100059.65 ns4.451 ns0.689 ns59.64 ns1.010.02--NA
ArraySeqSumJob-YRWBVV/p:BUILDING_USING_DOTNET=trueDefaultDefaultDefault100059.88 ns0.326 ns0.305 ns59.84 ns1.000.00--NA
ArraySeqSumJob-YQEOCP/p:BUILDING_USING_DOTNET=trueDefaultDefaultDefault1000496.87 ns6.676 ns6.245 ns493.92 ns8.300.10-32 BNA
ArraySeqSumJob-SEOIASDefault221100060.61 ns3.319 ns0.514 ns60.63 ns1.010.01--NA
ListSeqSumJob-YRWBVV/p:BUILDING_USING_DOTNET=trueDefaultDefaultDefault10001,163.60 ns18.969 ns17.743 ns1,162.85 ns1.000.00-40 B1.00
ListSeqSumJob-YQEOCP/p:BUILDING_USING_DOTNET=trueDefaultDefaultDefault10001,230.86 ns6.546 ns6.123 ns1,233.38 ns1.060.02-40 B1.00
ListSeqSumJob-SEOIASDefault22110001,156.84 ns66.957 ns10.362 ns1,153.00 ns0.990.020.001940 B1.00
ArraySumJob-YRWBVV/p:BUILDING_USING_DOTNET=trueDefaultDefaultDefault10000553.52 ns6.542 ns6.119 ns554.30 ns1.000.00--NA
ArraySumJob-YQEOCP/p:BUILDING_USING_DOTNET=trueDefaultDefaultDefault100001,944.70 ns16.488 ns15.423 ns1,944.27 ns3.510.05--NA
ArraySumJob-SEOIASDefault22110000547.22 ns53.119 ns8.220 ns543.16 ns1.000.02--NA
ArraySeqSumJob-YRWBVV/p:BUILDING_USING_DOTNET=trueDefaultDefaultDefault10000549.69 ns2.934 ns2.600 ns549.51 ns1.000.00--NA
ArraySeqSumJob-YQEOCP/p:BUILDING_USING_DOTNET=trueDefaultDefaultDefault100004,843.34 ns64.337 ns60.181 ns4,826.96 ns8.820.12-32 BNA
ArraySeqSumJob-SEOIASDefault22110000551.21 ns42.723 ns6.611 ns550.96 ns1.000.02--NA
ListSeqSumJob-YRWBVV/p:BUILDING_USING_DOTNET=trueDefaultDefaultDefault1000013,349.78 ns349.754 ns992.195 ns12,981.18 ns1.000.00-40 B1.00
ListSeqSumJob-YQEOCP/p:BUILDING_USING_DOTNET=trueDefaultDefaultDefault1000014,501.57 ns311.609 ns894.065 ns14,248.72 ns1.090.10-40 B1.00
ListSeqSumJob-SEOIASDefault2211000012,715.10 ns817.355 ns126.487 ns12,726.11 ns0.990.03-40 B1.00

By quick look of this, the Sum seems to be improved but the Average not really. Should I revert the Average change?

Edit: Average removed

@T-Gro

Copy link
Copy Markdown
Member

1/As per your benchmark, the cost for average might be dominated by doing the casts and additional array allocations.
Better try without the mapping step for fully prove that.

2/The idea of using if !DEBUG - if this is placed in the added code, it would react on conditional defines of when we build FSharp.Core (.e.g when packing together a new .NET SDK), not based on user code. The shipped FSharp.Core is always RELEASE, even if user code is in DEBUG.

@vzarytovskii

Copy link
Copy Markdown
Member

What are the numbers when running on the full clr? 4.6-4.8? Enumerable probably has different implementation there.

@vzarytovskii

Copy link
Copy Markdown
Member

Average will now throw different exception, so it's a breaking change at this point.

@T-Gro

Copy link
Copy Markdown
Member

Average will now throw different exception, so it's a breaking change at this point.

@Thorium:
This does not mean it is a showstopper.
If the change is good from all other perspective, such break can be accepted and documented (especially if it will be e.g. just a subtype from the same exception type).

@Thorium

Copy link
Copy Markdown
ContributorAuthor

Better try without the mapping step for fully prove that.

I did check this, and the results were worse for Enumerable.Sum
Main - current

MethodJobArgumentsIterationCountLaunchCountWarmupCountLengthMeanErrorStdDevRatioRatioSDAllocatedAlloc Ratio
ArrayAverageJob-XSEKSP/p:BUILDING_USING_DOTNET=trueDefaultDefaultDefault1000291.8 ns5.86 ns8.21 ns1.000.00-NA
ArrayAverageJob-BKIONA/p:BUILDING_USING_DOTNET=trueDefaultDefaultDefault1000276.6 ns1.25 ns1.11 ns0.940.02-NA
ArrayAverageJob-LYJOOZDefault2211000277.2 ns4.73 ns0.73 ns0.930.02-NA
ArrayAverageJob-XSEKSP/p:BUILDING_USING_DOTNET=trueDefaultDefaultDefault100002,669.5 ns12.65 ns11.21 ns1.000.00-NA
ArrayAverageJob-BKIONA/p:BUILDING_USING_DOTNET=trueDefaultDefaultDefault100002,661.2 ns4.14 ns3.87 ns1.000.00-NA
ArrayAverageJob-LYJOOZDefault221100002,677.6 ns139.82 ns21.64 ns1.000.01-NA
---------------------------------------------------------------------------------------------------------------:----------:---------:------:--------:----------:------------:

This PR - with Enumerable.Sum

MethodJobArgumentsIterationCountLaunchCountWarmupCountLengthMeanErrorStdDevRatioAllocatedAlloc Ratio
ArrayAverageJob-GSXKPB/p:BUILDING_USING_DOTNET=trueDefaultDefaultDefault1000503.6 ns9.32 ns8.71 ns1.00-NA
ArrayAverageJob-LFPVKH/p:BUILDING_USING_DOTNET=trueDefaultDefaultDefault1000275.4 ns0.45 ns0.37 ns0.55-NA
ArrayAverageJob-SPCSFFDefault2211000494.2 ns12.91 ns2.00 ns0.98-NA
ArrayAverageJob-GSXKPB/p:BUILDING_USING_DOTNET=trueDefaultDefaultDefault100005,277.4 ns8.47 ns7.07 ns1.00-NA
ArrayAverageJob-LFPVKH/p:BUILDING_USING_DOTNET=trueDefaultDefaultDefault100002,661.1 ns4.92 ns4.11 ns0.50-NA
ArrayAverageJob-SPCSFFDefault221100005,280.3 ns83.03 ns12.85 ns1.00-NA

…nchmarks\CompiledCodeBenchmarks\MicroPerf\MicroPerf.fsproj
@Thorium

Copy link
Copy Markdown
ContributorAuthor

Because this is just a forwarding, behaviour difference is easy to explore already in FSI:

[|1.; Double.NaN |]|> Array.sum;;//val it: float = nan[|1.; Double.NaN |]|> System.Linq.Enumerable.Sum;;//val it: float = nan

Seems to be the same with edge-cases like Double.MaxValue and infinity.

@Thorium

Copy link
Copy Markdown
ContributorAuthor

What are the numbers when running on the full clr? 4.6-4.8? Enumerable probably has different implementation there.

I don't know how to easily run MicroPerf.fsproj in .NET, but I can run
array |> Array.sum vs array |> System.Linq.Enumerable.Sum in .NET 4.8.

Benchmarks.LinqEnumerableSum: .NET Framework 4.8(Runtime=.NET Framework 4.8) [Length=10000]
Runtime = .NET Framework 4.8.1 (4.8.9310.0), X64 RyuJIT VectorSize=256; GC = Concurrent Workstation
Mean = 29.934 us, StdErr = 0.069 us (0.23%), N = 15, StdDev = 0.266 us
Min = 29.483 us, Q1 = 29.784 us, Median = 29.985 us, Q3 = 30.094 us, Max = 30.387 us
IQR = 0.310 us, LowerFence = 29.320 us, UpperFence = 30.558 us
ConfidenceInterval = [29.649 us; 30.219 us] (CI 99.9%), Margin = 0.285 us (0.95% of Mean)
Skewness = -0.15, Kurtosis = 1.96, MValue = 2
-------------------- Histogram --------------------
[29.341 us ; 30.529 us) | @@@@@@@@@@@@@@@
---------------------------------------------------

// * Summary *

BenchmarkDotNet v0.13.12, Windows 11 (10.0.26100.3915)
13th Gen Intel Core i9-13900H, 1 CPU, 20 logical and 14 physical cores
[Host] : .NET Framework 4.8.1 (4.8.9310.0), X64 LegacyJIT VectorSize=256 DEBUG
.NET Framework 4.8 : .NET Framework 4.8.1 (4.8.9310.0), X64 RyuJIT VectorSize=256

Job=.NET Framework 4.8 Runtime=.NET Framework 4.8

MethodLengthMeanErrorStdDevRatioRatioSDGen0AllocatedAlloc Ratio
FSharpArraySum1000456.7 ns8.08 ns7.56 ns1.000.00--NA
LinqEnumerableSum10003,033.3 ns51.61 ns48.28 ns6.640.070.003832 BNA
FSharpArraySum100004,480.0 ns55.90 ns52.29 ns1.000.00--NA
LinqEnumerableSum1000029,933.7 ns284.86 ns266.46 ns6.680.10-32 BNA

Not exactly the results I was hoping for.
Any ideas?

@T-Gro

Copy link
Copy Markdown
Member

Framework-dependent runtime switch would solve this (and since it is static, I would hope JIT would eliminate such branching), but is not something we are doing in FSharp.Core.

On Desktop:

  • Only forward Seq calls to Enumerable, if anything (or maybe just keep as it is )

On Core:

  • Forward Seq.sum and Array.sum. Exclude average due to reasons above.

It would be great if we could make use of https://learn.microsoft.com/en-us/dotnet/api/system.numerics.tensors.tensorprimitives.sum?view=net-9.0-pp#system-numerics-tensors-tensorprimitives-sum-1(system-readonlyspan((-0))) for some of those implementations. Right now, any such support depending on Tensors will have to be done as a separate lib outside of FSharp.Core .

@vzarytovskii

Copy link
Copy Markdown
Member

Not exactly the results I was hoping for.
Any ideas?

Yeah, that's what I suspected will happen :( Bcl in full framework doesn't have those functions vectorised.

And the issue with fslib is that it ships as netstandard, so there's no way to tailor separate implementations for netfx and netcore.

@Thorium

Copy link
Copy Markdown
ContributorAuthor

The IEnumerable.Sum of NET 9 checks the support for vectors via Vector<T>.IsSupported and Vector.IsHardwareAccelerated.
But I assume there is not even a type of System.Numerics.Vector<T> in .NET Framework 4.8. / netstandard2.0.
Would there be a fast way to check something like Type.GetType("System.Numerics.Vector") <> null to check in static optimization conditionals?

On Desktop:

What do you mean by "On Desktop" and "On Core" ?

I can see that in FSharp.Core.fsproj there is <TargetFrameworks Condition="'$(Configuration)' != 'Proto'">netstandard2.0;netstandard2.1</TargetFrameworks> so there would be possible to do:
<DefineConstants Condition=" '$(TargetFramework)' == 'netstandard2.1' >$(DefineConstants);NETSTANDARD21</DefineConstants>
And then use that, however, this would add one more parallel code-path for long-term maintenance.

@vzarytovskii

vzarytovskii commented Apr 28, 2025

Copy link
Copy Markdown
Member

The IEnumerable.Sum of NET 9 checks the support for vectors via Vector<T>.IsSupported and Vector.IsHardwareAccelerated.

But I assume there is not even a type of System.Numerics.Vector<T> in .NET Framework 4.8. / netstandard2.0.

Would there be a fast way to check something like Type.GetType("System.Numerics.Vector") <> null to check in static optimization conditionals?

Reflection can likely neglect many optimizations (needs proving tho).

On Desktop:

What do you mean by "On Desktop" and "On Core" ?

Desktop is usually referred when full framework is used, and core is when coreclr runtime and bcl are used.

I can see that in FSharp.Core.fsproj there is <TargetFrameworks Condition="'$(Configuration)' != 'Proto'">netstandard2.0;netstandard2.1</TargetFrameworks> so there would be possible to do:

<DefineConstants Condition=" '$(TargetFramework)' == 'netstandard2.1' >$(DefineConstants);NETSTANDARD21</DefineConstants>

And then use that, however, this would add one more parallel code-path for long-term maintenance.

These should already be defined. However they will be defined on both net48 and net9.0

@T-Gro

T-Gro commented Apr 28, 2025

Copy link
Copy Markdown
Member

I meant a statically stored flag based on something like:

openSystem.Runtime.InteropServicesletruntimeDescription= RuntimeInformation.FrameworkDescription
lethasLinqAcceleration= runtimeDescription >"..."// string-based logic here

And switching at runtime (so cannot be part of statically optimized when clauses in FSharp.Core, since those worked with inlining at compile-time.

e.g. top level function:
For unsupported types (anything but numerics), it would immediately call the existing code.
For statically optimized numeric primitives, it would then do the decision based on this static flag, and pick between existing code and Enumerable.Sum.

@Thorium

Copy link
Copy Markdown
ContributorAuthor

And switching at runtime

Runtime check added. Compared to this
#18509 (comment)
...this PR Array.sum still beats the current main over 50%:

This PR:

MethodJobArgumentsIterationCountLaunchCountWarmupCountLengthMeanErrorStdDevMedianRatioRatioSDGen0AllocatedAlloc Ratio
ArraySumJob-HPWEPX/p:BUILDING_USING_DOTNET=trueDefaultDefaultDefault100071.20 ns0.395 ns0.330 ns71.28 ns1.000.00--NA
ArraySumJob-FURVPH/p:BUILDING_USING_DOTNET=trueDefaultDefaultDefault1000203.16 ns1.536 ns1.437 ns202.73 ns2.850.03--NA
ArraySumJob-RIJJDMDefault221100069.23 ns3.499 ns0.541 ns69.03 ns0.970.00--NA
ArraySeqSumJob-HPWEPX/p:BUILDING_USING_DOTNET=trueDefaultDefaultDefault100072.44 ns1.457 ns1.842 ns71.38 ns1.000.00--NA
ArraySeqSumJob-FURVPH/p:BUILDING_USING_DOTNET=trueDefaultDefaultDefault1000497.44 ns6.606 ns5.856 ns495.07 ns6.790.22-32 BNA
ArraySeqSumJob-RIJJDMDefault221100070.06 ns2.019 ns0.312 ns70.09 ns0.940.01--NA
ListSeqSumJob-HPWEPX/p:BUILDING_USING_DOTNET=trueDefaultDefaultDefault10001,143.31 ns7.123 ns6.663 ns1,140.53 ns1.000.00-40 B1.00
ListSeqSumJob-FURVPH/p:BUILDING_USING_DOTNET=trueDefaultDefaultDefault10001,227.64 ns13.709 ns12.823 ns1,222.18 ns1.070.01-40 B1.00
ListSeqSumJob-RIJJDMDefault22110001,153.34 ns41.507 ns6.423 ns1,155.69 ns1.000.010.001940 B1.00
ArraySumJob-HPWEPX/p:BUILDING_USING_DOTNET=trueDefaultDefaultDefault10000559.23 ns6.984 ns6.191 ns559.52 ns1.000.00--NA
ArraySumJob-FURVPH/p:BUILDING_USING_DOTNET=trueDefaultDefaultDefault100001,939.43 ns19.240 ns17.997 ns1,932.29 ns3.470.04--NA
ArraySumJob-RIJJDMDefault22110000550.02 ns50.072 ns7.749 ns549.63 ns0.990.01--NA
ArraySeqSumJob-HPWEPX/p:BUILDING_USING_DOTNET=trueDefaultDefaultDefault10000568.95 ns10.940 ns13.836 ns564.94 ns1.000.00--NA
ArraySeqSumJob-FURVPH/p:BUILDING_USING_DOTNET=trueDefaultDefaultDefault100004,784.67 ns60.194 ns56.306 ns4,776.55 ns8.410.23-32 BNA
ArraySeqSumJob-RIJJDMDefault22110000561.27 ns85.016 ns13.156 ns557.93 ns0.960.03--NA
ListSeqSumJob-HPWEPX/p:BUILDING_USING_DOTNET=trueDefaultDefaultDefault1000014,808.61 ns704.739 ns2,077.937 ns14,333.68 ns1.000.00-40 B1.00
ListSeqSumJob-FURVPH/p:BUILDING_USING_DOTNET=trueDefaultDefaultDefault1000016,225.89 ns609.042 ns1,795.773 ns16,355.29 ns1.110.18-40 B1.00
ListSeqSumJob-RIJJDMDefault2211000015,371.93 ns14,341.671 ns2,219.390 ns15,512.85 ns1.000.23-40 B1.00

@Thorium

Thorium commented Apr 28, 2025

Copy link
Copy Markdown
ContributorAuthor

After Sum is solved, I guess it's time for SumBy to do the same, because SumBy is the more commonly used in F#. There is System.Linq.Enumerable.Sum overload that takes a tuple of IEnumerable and a projection.

Comment threadsrc/FSharp.Core/array.fs
Comment threadsrc/FSharp.Core/array.fs Outdated
Comment threadsrc/FSharp.Core/array.fs Outdated
@T-Gro

Copy link
Copy Markdown
Member

After Sum is solved, I guess it's time for SumBy to do the same, because SumBy is the more commonly used in F#. There is System.Linq.Enumerable.Sum overload that takes a tuple of IEnumerable and a projection.

Is it vectorized as well?
Hard to imagine how - since with a projection, the data to sum by is not stored next to each other.

Is it doing copies first? I am curious.

@Thorium

Copy link
Copy Markdown
ContributorAuthor

Oh, you are right, it isn't. That's sad, makes this very marginal feature especially when tools like FSharpLint prefer sumBy over (map >> sum)

@Thorium

Copy link
Copy Markdown
ContributorAuthor

However they will be defined on both net48 and net9.0

.NET Framework is not .NET Standard 2.1 compatible, thus .NET Standard 2.1 version shouldn't need runtime check.
I expect the "supported" frameworks (aka. where things should be as fast as possible) are only:

  • .NET Framework Full (4.62-...) with .NET Standard 2.0
  • dotnet 8/9 (both .NET Standard 2.0 and .NET Standard 2,1 should work here)

@vzarytovskii

vzarytovskii commented Apr 28, 2025

Copy link
Copy Markdown
Member

However they will be defined on both net48 and net9.0

.NET Framework is not .NET Standard 2.1 compatible, thus .NET Standard 2.1 version shouldn't need runtime check.

I expect the "supported" frameworks (aka. where things should be as fast as possible) are only:

  • .NET Framework Full (4.62-...) with .NET Standard 2.0

  • dotnet 8/9 (both .NET Standard 2.0 and .NET Standard 2,1 should work here)

Will all netstandard2.1 implementations be vectorized? If yes, then it's safe enough (and also it should be defined already).

Comment threadsrc/FSharp.Core/array.fs
@T-Gro

T-Gro commented Jun 3, 2025

Copy link
Copy Markdown
Member

However they will be defined on both net48 and net9.0

.NET Framework is not .NET Standard 2.1 compatible, thus .NET Standard 2.1 version shouldn't need runtime check. I expect the "supported" frameworks (aka. where things should be as fast as possible) are only:

  • .NET Framework Full (4.62-...) with .NET Standard 2.0
  • dotnet 8/9 (both .NET Standard 2.0 and .NET Standard 2,1 should work here)

@Thorium : I think the discussion about the future deployments of FSharp.Core derailed the discussion.
It is a very valid point, but not one which should prevent this PR from happening.

(It's not that a different strategy for shipping FSharp.Core for netCurrent is dismissed as not being allowed, it is rather work which did not happen yet.)

I would like this optimization to go into NET10 as is, are you still interested in finalizing this PR and getting it in?

@Thorium

Copy link
Copy Markdown
ContributorAuthor

I would like this optimization to go into NET10 as is, are you still interested in finalizing this PR and getting it in?

Sure. Is there anything that should be added still? I think all the existing conversation items were already resolved?

@T-Gro

T-Gro commented Jun 3, 2025

Copy link
Copy Markdown
Member

Tests were failing, otherwise it was good.
The results have expired since, let me get fresh results.


acc

let isNetFramework = System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription.StartsWith ".NET Framework"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Is this going to functionally compile to a static readonly bool, so that the JIT can optimize things appropriately?

Is this going to do the "wrong" thing on custom runtimes or scenarios where vectorization may not be available or possible? For example, there was no SIMD acceleration on 32-bit Unix for a while and there is non on Arm32 today. Likewise, acceleration can be disabled via environment variables for testing purposes.

In general it's expected that Enumerable.Sum is going to do the most optimal thing over time based on the underlying hardware and other user options (like if you're compiling for size vs speed).

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Is this going to functionally compile to a static readonly bool, so that the JIT can optimize things appropriately?

This is runtime check, not compile-time check. Because nothing says the code is compiled and run on similar machines.

Is this going to do the "wrong" thing on custom runtimes or scenarios where vectorization may not be available or possible? For example, there was no SIMD acceleration on 32-bit Unix for a while and there is non on Arm32 today. Likewise, acceleration can be disabled via environment variables for testing purposes.

No, because Enumerable.Sum already checks that within its implementation.
The only reason for this check is because Enumerable.Sum is slow on old .NET Framework.

@tannergoodingtannergoodingJun 18, 2025

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This is runtime check, not compile-time check. Because nothing says the code is compiled and run on similar machines.

Yes, which is why you should ensure it compiles down to a static readonly bool in IL. Because that will cause it to be initialized at runtime in Tier 0 and then allow the JIT to treat it as a constant in Tier 1 (or for NativeAOT), allowing the check to be elided once we do know the actual machine/runtime it's running on.

The only reason for this check is because Enumerable.Sum is slow on old .NET Framework.

The point was that you're doing a specific check for .NET Framework, which doesn't account for custom runtimes or other scenarios that may or may not be relevant. So I'm just asking if the nuance of that has been fully considered.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Hi @tannergooding , I think you have it right - Enumerable.Sum will do the best thing that is available on all versions of modern .NET. Even without vectorization, it will still detect when the sequence passed to it can be treated as an ReadOnlySpan.

It just does not apply to desktop framework, which is still supported and can be used with latest F# and latest FSharp.Core - there the Enumerable.Sum is slower than Array.sum in FSharp.Core.

@tannergooding :
Is there a recommended way to locally benchmark a modern .NET version, however with intentionallydisabled vectorization? (to proof that .NET 9/10 ; even when not vectorized, does not carry the drastical perf worsening visible at .NET Framework implementation of Enumerable.Sum compared to FSharp.Core's Array.sum ? )

@Happypig375Happypig375Jun 19, 2025

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

@T-Gro I think he means that the check should be emitted such that it should be optimized away by JIT and directly use the relevant implementation depending on framework - Array.sum on .NET Framework, Enumerable.Sum otherwise.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

That is one part of the concern - F#'s emit for letdoes not useinitonly because of the file+module based initialization semantics which guarantees execution order as written in the file.

The other part of the concern is if indeed Enumerable.Sum is not worse for any configuration not .NET Framework, especially environments without vectorization support.

@T-Gro

Copy link
Copy Markdown
Member

@Thorium :

As I am revisiting open PRs before NET10 closes, I again came across this one.
The remaining concern is the one raised by Tanner, that is how much worse is a non-desktop implementation that is not HW accelerated.

The check for HW acceleration happens within Enumerable.Sum itself and is correct to be there, that is fine.

If you are still interested in pursuing that, I think that this last remaining concern could be lifted by running a test on:
.net9 target, but with DOTNET_EnableHWIntrinsic=0 enviroment variable. That will hit the "new" code path of Enumerable.Sum, however not use HW acceleration.
The goal is not to be drastically worse than existing F# Array.sum.

If this holds, I would be happy to merge it in 👍 .

As far as the runtime check in FSharp.Core goes, it is not up to this PR to restructure it - it shall use the already implemented mechanism.
Multitargeting of FSharp.Core (easy) and assessing the full downstream impact (hard) shall be taken separately.

@Thorium

Thorium commented Nov 2, 2025

Copy link
Copy Markdown
ContributorAuthor

Hi,

The remaining concern is the one raised by Tanner, that is how much worse is a non-desktop implementation that is not HW accelerated.

This case was already tested before earlier in this thread: #18509 (comment)
...and the answer was: almost 10x worse.

@T-Gro

T-Gro commented Aug 18, 2026

Copy link
Copy Markdown
Member

With the introduction of #20229, we can now have if-def-based additions to FSharp.Core that rely on newer runtime APIs. We could therefore reference System.Numerics.Tensors.TensorPrimitives (net8+) — e.g. TensorPrimitives.Sum — plus System.Runtime.Intrinsics for a vectorized Array.sum/Array.average fast path in array.fs under #if NET, with the public signature unchanged. (Bare Vector<T> was already ns2.1-reachable, so TensorPrimitives + intrinsics + better JIT SIMD is the real net-only win here.)

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: New

Development

Successfully merging this pull request may close these issues.

7 participants

@Thorium@T-Gro@vzarytovskii@kerams@tannergooding@brianrourkeboll@Happypig375