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]を返す。-
right[index] == left[index]かつright[index]の符号ビットが1である、または -
left[index] != left[index](つまりleft[index]がNaN) である、または -
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]を返す。-
right[index] == left[index]かつleft[index]の符号ビットが1である、または -
left[index] != left[index](つまりleft[index]がNaN) である、または -
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>)(Tはfloatまたはdouble) の挙動が異なる。 - .NET 9.0 での
VectorXXX.Max(VectorXXX<T>, VectorXXX<T>)およびVectorXXX.Min(VectorXXX<T>, VectorXXX<T>)(Tはfloatまたはdouble) の挙動は、double.Max(double, double)およびdouble.Min(double, double)の挙動と似ている。 - .NET 9.0 の
VectorXXX.Max(VectorXXX<T>, VectorXXX<T>)およびVectorXXX.Min(VectorXXX<T>, VectorXXX<T>)(Tはfloatまたはdouble) の機械語コードは .NET 8.0 より長いため、パフォーマンスに影響があると思われる。これはNaNや+0、-0を正しく判定するための処理が増えたためである。