1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

c# 浮動小数点数のベクター操作の Min/Max ではまった件について

Posted at

1. 概要

.NET 9.0 で VectorXXX.MaxNumber(VectorXXX<T>, VectorXXX<T>)VectorXXX.MinNumber(VectorXXX<T>, VectorXXX<T>) が新設されましたが、それと同時に従来からあった VectorXXX.Max(VectorXXX<T>, VectorXXX<T>)VectorXXX.Min(VectorXXX<T>, VectorXXX<T>) の挙動も .NET 9.0 で一部変わっています。
挙動が変わったのは、要素が浮動小数点数型 (float / double) の場合のみのようです。

ちなみに、.NET 9 での破壊的変更 にはそれらしい記事は見当たりません。どう考えても破壊的変更だと思うのですが…

本稿では、Min / Max メソッドの変更内容と、「何故変更されたのか」について検証/考察しています。
なお、本文中では、説明の都合上 double 型についてのみ言及しています。

多分 .NET ランタイムのリポジトリを探せばこれに関する議論は見つかると思うのですが、何分筆者の外国語適性がやばいので、自力で調査して備忘録のついでに投稿することにしました…。

2. 実行環境

項目
OS Microsoft Windows 10.0.19045
Architecture X64
.NET Runtime .NET 8.0.19 / .NET 9.0.8
ハードウェアアクセラレーション サポートの有無
AdvSimd.IsSupported False
PackedSimd.IsSupported False
Sse.IsSupported True
Sse2.IsSupported True
Sse3.IsSupported True
Ssse3.IsSupported True
Sse41.IsSupported True
Sse42.IsSupported True
Avx.IsSupported True
Avx2.IsSupported True
Avx512F.IsSupported False
Avx512CD.IsSupported False
Avx512DQ.IsSupported False
Avx512BW.IsSupported False
Avx512Vbmi.IsSupported False
Avx10v1.IsSupported False

3. .NET 8.0 と .NET 9.0 での Min / Max メソッドの挙動の違い

どう変わったかは以下のコードを実行すればすぐわかります。

Console.WriteLine(Vector256.Min(Vector256.Create(1.0), Vector256.Create(double.NaN)));
Console.WriteLine(Vector256.Max(Vector256.Create(1.0), Vector256.Create(double.NaN)));
Console.WriteLine(Vector256.Min(Vector256.Create(double.NaN), Vector256.Create(1.0)));
Console.WriteLine(Vector256.Max(Vector256.Create(double.NaN), Vector256.Create(1.0)));

.NET 8.0 では以下のように表示されます。

<NaN, NaN, NaN, NaN>
<NaN, NaN, NaN, NaN>
<1, 1, 1, 1>
<1, 1, 1, 1>

そして、.NET 9.0 では以下のように表示されます。

<NaN, NaN, NaN, NaN>
<NaN, NaN, NaN, NaN>
<NaN, NaN, NaN, NaN>
<NaN, NaN, NaN, NaN>

おそらくですが、従来からあった以下のメソッドと類似の意味を持つ機能をベクター操作でサポートする意図により、ベクター操作クラスへの MinNumber / MaxNumber メソッドの新設、および既存の Min / Max メソッドの動作の変更が行われたのだと思います。

  • double.Max(double, double)
  • double.Min(double, double)
  • double.MaxNumber(double, double)
  • double.MinNumber(double, double)

そもそも、.NET 8.0 で Vector256.Max(vector1, vector2)Vector256.Max(vector2, vector1) が一致しないことがある (対称性がない) 時点でアウトだと思うので、そこら辺の事情もあるのではないでしょうか。

4. .NET 8.0 での Min / Max メソッドの挙動

以下のリンクは、ベクター操作クラスの Min / Max に様々な値を与えた実行結果の表です。

これらの結果から、Min / Max のどちらも NaN に関する対称性がない (例えば Max(x, double.NaN) と Max(double.NaN, x) が等しくないことがある) ことがわかります。

じゃあ何故 .NET 8.0 でそんな挙動だったのかという話になります。
とりあえず、以下のコードを .NET 8.0 上でデバッグ実行して逆アセンブルしてみました。

[MethodImpl(MethodImplOptions.NoInlining | MethodImplOptions.AggressiveOptimization)]
public static Vector256<double> Max256(Vector256<double> left, Vector256<double> right)
    => Vector256.Max(left, right);

[MethodImpl(MethodImplOptions.NoInlining | MethodImplOptions.AggressiveOptimization)]
public static Vector256<double> Min256(Vector256<double> left, Vector256<double> right)
    => Vector256.Min(left, right);

逆アセンブル結果がこちらです。

    ; Max256()
    vzeroupper  
    vmovups     ymm0,ymmword ptr [rdx]  
    vmaxpd      ymm0,ymm0,ymmword ptr [r8]  
    vmovups     ymmword ptr [rcx],ymm0  
    mov         rax,rcx  
    vzeroupper  
    ret  

    ; Min256()
    vzeroupper  
    vmovups     ymm0,ymmword ptr [rdx]  
    vminpd      ymm0,ymm0,ymmword ptr [r8]  
    vmovups     ymmword ptr [rcx],ymm0  
    mov         rax,rcx  
    vzeroupper  
    ret  

最大値と最小値を求める部分が vmaxpd / vminpd 命令のみであることがわかります。
どうやら機械語命令 vmaxpd / vminpd の仕様がそのまま Max / Min メソッドの挙動に反映されてしまっていたようです。
何で vmaxpd / vminpd 命令がそんなけったいな仕様なのかという問題はさておき。

5. .NET 9.0 での Min / Max メソッドの仕組み

前述したように、.NET 9.0 ではベクター操作の Min / Max メソッドは それぞれ double.Min(double, double) / double.Max(double, double) と同様の挙動に変わったようです。
具体的には以下のような動作をします。

  • VectorXXX.Min(VectorXXX<double> left, VectorXXX<double> right) の場合
    left の要素と right の対応する要素の小さい方の値が結果のベクターの該当する要素として返る。ただし、left の要素と right の要素のどちらかが NaN である場合は NaN が返る。
  • VectorXXX.Max(VectorXXX<double> left, VectorXXX<double> right) の場合
    left の要素と right の対応する要素の大きい方の値が結果のベクターの該当する要素として返る。ただし、left の要素と right の要素のどちらかが NaN である場合は NaN が返る。

では、どのように実行されるのかを調べるために、前述の Max256() メソッドと Min256() メソッドを .NET 9.0 上でデバッグ実行して逆アセンブルしてみました。

    ; Max256()
    vmovups     ymm0,ymmword ptr [rdx]  
    vmovups     ymm1,ymmword ptr [r8]  
    vcmpeqpd    ymm2,ymm1,ymm0  
    vxorps      ymm3,ymm3,ymm3  
    vpcmpgtq    ymm3,ymm3,ymm1  
    vandpd      ymm2,ymm3,ymm2  
    vcmpneqpd   ymm3,ymm0,ymm0  
    vorpd       ymm2,ymm3,ymm2  
    vcmpltpd    ymm3,ymm1,ymm0  
    vorpd       ymm2,ymm3,ymm2  
    vblendvpd   ymm0,ymm1,ymm0,ymm2  
    vmovups     ymmword ptr [rcx],ymm0  
    mov         rax,rcx  
    vzeroupper  
    ret  

    ; Min256()
    vmovups     ymm0,ymmword ptr [rdx]  
    vmovups     ymm1,ymmword ptr [r8]  
    vcmpeqpd    ymm2,ymm1,ymm0  
    vxorps      ymm3,ymm3,ymm3  
    vpcmpgtq    ymm3,ymm3,ymm0  
    vandpd      ymm2,ymm3,ymm2  
    vcmpneqpd   ymm3,ymm0,ymm0  
    vorpd       ymm2,ymm3,ymm2  
    vcmpltpd    ymm3,ymm0,ymm1  
    vorpd       ymm2,ymm3,ymm2  
    vblendvpd   ymm0,ymm1,ymm0,ymm2  
    vmovups     ymmword ptr [rcx],ymm0  
    mov         rax,rcx  
    vzeroupper  
    ret  

.NET 8.0 に比べてかなり長いコードになっています。

vmaxpd / vminpd 命令を全く使わないようになったことだけはすぐわかりますが、これだと流れがわかりにくいので、同一内容の機械語にコンパイルされる c# コードを組んでみました。(めっちゃ疲れました…)
c# コードの検証には、デバッグ実行の他に SharpLab さんにも大変お世話になりました。
以下が、その c# コードになります。

// equivalent to "Vector256.Max(Vector256<double>, Vector256<double>)" in .NET 9.0
public static Vector256<double> ImplementOfMax(Vector256<double> left, Vector256<double> right)
{
    return
        Vector256.ConditionalSelect(
            (Avx.CompareEqual(right, left)) & Vector256.LessThan(right.AsInt64(), Vector256<long>.Zero).AsDouble()
                | Avx.CompareNotEqual(left, left)
                | Vector256.LessThan(right, left),
            left,
            right);
}

// equivalent to "Vector256.Min(Vector256<double>, Vector256<double>)" in .NET 9.0
public static Vector256<double> ImplementOfMin(Vector256<double> left, Vector256<double> right)
{
    return
        Vector256.ConditionalSelect(
            (Avx.CompareEqual(right, left)) & Vector256.LessThan(left.AsInt64(), Vector256<long>.Zero).AsDouble()
                | Avx.CompareNotEqual(left, left)
                | Vector256.LessThan(left, right),
            left,
            right);
}

ImplementOfMax() の処理の流れを大まかに説明すると以下のようになります。

  • ベクトルの各要素 (left[0], left[1], … , left[index], … , left[Vector256<double>.Count - 1] および、right[0], right[1], … , right[index], … , right[Vector256<double>.Count - 1]) 毎に、以下の条件を満たす場合は left[index]、満たさない場合は right[index] を返す。
    1. right[index] == left[index] かつ right[index] の符号ビットが 1 である、または
    2. left[index] != left[index] (つまり left[index]NaN) である、または
    3. right[index] < left[index] である場合。

同様に、ImplementOfMin() の処理の流れは以下のようになります。

  • ベクトルの各要素 (left[0], left[1], … , left[index], … , left[Vector256<double>.Count - 1] および、right[0], right[1], … , right[index], … , right[Vector256<double>.Count - 1]) 毎に、以下の条件を満たす場合は left[index]、満たさない場合は right[index] を返す。
    1. right[index] == left[index] かつ left[index] の符号ビットが 1 である、または
    2. left[index] != left[index] (つまり left[index]NaN) である、または
    3. left[index] < right[index] である場合。

それぞれ、2 と 3 の条件はわかるのですが、1 の条件って何故必要なのでしょうか?
そう思って、敢えて 1 の条件を省略して実験してみたところ、以下のパターンの際に NG であることが分かりました。

  • left の要素が +0 かつ、対応する right の要素が -0 である場合
    Vector256.Max(Vector256<double>, Vector256<double>) では復帰値の該当する要素は +0 であるが、1 の条件を省略した ImplementOfMax()では -0 である。
  • left の要素が -0 かつ、対応する right の要素が +0 である場合
    Vector256.Min(Vector256<double>, Vector256<double>) では復帰値の該当する要素は -0 であるが、1 の条件を省略した ImplementOfMin()では +0 である。

1 の条件は +0-0 の比較のために必要だということですね。疑問が解決してすっきりしました。

6. まとめ

  • .NET 8.0 と .NET 9.0 では、VectorXXX.Max(VectorXXX<T>, VectorXXX<T>) および VectorXXX.Min(VectorXXX<T>, VectorXXX<T>) (Tfloat または double) の挙動が異なる。
  • .NET 9.0 での VectorXXX.Max(VectorXXX<T>, VectorXXX<T>) および VectorXXX.Min(VectorXXX<T>, VectorXXX<T>) (Tfloat または double) の挙動は、double.Max(double, double) および double.Min(double, double) の挙動と似ている。
  • .NET 9.0 の VectorXXX.Max(VectorXXX<T>, VectorXXX<T>) および VectorXXX.Min(VectorXXX<T>, VectorXXX<T>) (Tfloat または double) の機械語コードは .NET 8.0 より長いため、パフォーマンスに影響があると思われる。これは NaN+0-0 を正しく判定するための処理が増えたためである。
1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?