.NET8での配列要素に対する一部Math関数の高速化について


# 概要

- .NET8では配列の要素に対して`Math.Sqrt`や`Math.Floor`を適用する場合に処理がやや遅い
- なぜか`+0.0`や`/1.0`などの無駄な操作を書くと速くなる
- 多分ビルド時orランタイムの最適化の問題だと思うが原因不明

# 詳細

.NET8で配列の要素を`Math.Sqrt`とか`Math.Floor`するときに配列の要素を直接入れるより、値の変わらない自明な計算を挟んだ方がパフォーマンスが2倍以上よくなる場合があったのでメモとして記事にしておきます。

## 使用したコード

長さ1億のdoubleの配列を作成して、forループで各要素の平方根や床関数の値を計算して合計値を返すプログラム。

<details><summary>使用したコード</summary>

```xml
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="BenchmarkDotNet" Version="0.14.0" />
  </ItemGroup>

</Project>
```

```csharp
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System;

public class MyBenchmark
{
    private double[] data;

    [GlobalSetup]
    public void Setup()
    {
        // ベンチマーク用の配列を初期化
        int N = 100_000_000;
        data = new double[N];
        Random rand = new Random(Seed: 3);
        for (int i = 0; i < data.Length; i++)
        {
            data[i] = rand.NextDouble() * 1000;
        }
    }

    [Benchmark]
    public double FloorSimple()
    {
        double sum = 0;
        for (int i = 0; i < data.Length; i++)
        {
            sum += Math.Floor(data[i]);
        }
        return sum;
    }

    [Benchmark]
    public double FloorAdd0()
    {
        double sum = 0;
        for (int i = 0; i < data.Length; i++)
        {
            sum += Math.Floor(data[i] + 0.0);
        }
        return sum;
    }

    [Benchmark]
    public double FloorMulti1()
    {
        double sum = 0;
        for (int i = 0; i < data.Length; i++)
        {
            sum += Math.Floor(data[i] * 1.0);
        }
        return sum;
    }

    [Benchmark]
    public double FloorDiv1()
    {
        double sum = 0;
        for (int i = 0; i < data.Length; i++)
        {
            sum += Math.Floor(data[i] / 1.0);
        }
        return sum;
    }

    [Benchmark]
    public double SqrtSimple()
    {
        double sum = 0;
        for (int i = 0; i < data.Length; i++)
        {
            sum += Math.Sqrt(data[i]);
        }
        return sum;
    }

    [Benchmark]
    public double SqrtAdd0()
    {
        double sum = 0;
        for (int i = 0; i < data.Length; i++)
        {
            sum += Math.Sqrt(data[i] + 0.0);
        }
        return sum;
    }

    [Benchmark]
    public double SqrtMulti1()
    {
        double sum = 0;
        for (int i = 0; i < data.Length; i++)
        {
            sum += Math.Sqrt(data[i] * 1.0);
        }
        return sum;
    }

    [Benchmark]
    public double SqrtDiv1()
    {
        double sum = 0;
        for (int i = 0; i < data.Length; i++)
        {
            sum += Math.Sqrt(data[i] / 1.0);
        }
        return sum;
    }

    public static void Main(string[] args)
    {
        var summary = BenchmarkRunner.Run<MyBenchmark>();

        // デバッグ用
        //var benchmark = new MyBenchmark();
        //benchmark.Setup();
        //Console.WriteLine(benchmark.FloorSimple());
        //Console.WriteLine(benchmark.FloorAdd0());
        //Console.WriteLine(benchmark.FloorMulti1());
        //Console.WriteLine(benchmark.FloorDiv1());
        //Console.WriteLine(benchmark.SqrtSimple());
        //Console.WriteLine(benchmark.SqrtAdd0());
        //Console.WriteLine(benchmark.SqrtMulti1());
        //Console.WriteLine(benchmark.SqrtDiv1());
    }
}
```
</details>

## 実行結果

// * Summary *

BenchmarkDotNet v0.14.0, Windows 11 (10.0.22631.4317/23H2/2023Update/SunValley3)
11th Gen Intel Core i7-11700 2.50GHz, 1 CPU, 16 logical and 8 physical cores
.NET SDK 8.0.403
  [Host]     : .NET 8.0.10 (8.0.1024.46610), X64 RyuJIT AVX-512F+CD+BW+DQ+VL+VBMI
  DefaultJob : .NET 8.0.10 (8.0.1024.46610), X64 RyuJIT AVX-512F+CD+BW+DQ+VL+VBMI


| Method      | Mean      | Error    | StdDev   |
|------------ |----------:|---------:|---------:|
| FloorSimple | 203.68 ms | 3.087 ms | 2.887 ms |
| FloorAdd0   |  96.73 ms | 1.077 ms | 0.955 ms |
| FloorMulti1 | 203.79 ms | 3.827 ms | 3.759 ms |
| FloorDiv1   | 105.35 ms | 1.387 ms | 1.297 ms |
| SqrtSimple  | 393.26 ms | 1.634 ms | 1.448 ms |
| SqrtAdd0    | 137.75 ms | 1.338 ms | 1.186 ms |
| SqrtMulti1  | 399.05 ms | 6.520 ms | 6.098 ms |
| SqrtDiv1    | 231.69 ms | 1.699 ms | 1.589 ms |

- `Math.Floor`の場合は`+0.0`と`/1.0`が何もしない場合に比べて2倍程度速い
- `Math.Sqrt`の場合は`+0.0`が何もしない場合に比べて2.8倍程度速い
- `Math.Sqrt`の場合は`/1.0`が何もしない場合に比べて1.7倍程度速い
- `*1.0`は効果がない


## 原因

分かりません。dotnetのリポジトリでもこの手の単純なパフォーマンス系のissueは結構ありそうですがあんまり調べられていません。

アセンブリ読める人とか類似の事例を知っている人がいたら教えてください。

## その他

以下は適当検証なので参考程度に（真面目に記録していないので勘違いがあるかも）。

- `+1.0`とかでも処理時間的には同じになりそう
  - 多分配列の要素を直接`Math.Floor`に入れるのがよくない
  - ただそれだと`*1.0`で効果がない理由が不明なので違うかも
- .NET6向けにビルドしても同じ
- 動的PGOを無効にしても同じ
- `Span`しても変わらない
- `Math.Floor`を別の自作関数でwrapperしても同じ結果
  - ただしこの場合は.NET6では`+0.0`なしでも高速化する場合もあった
- `Math.Sin`では`+0.0`や`/1.0`は効果がなく、むしろ悪化した