TadaoYamaokaの開発日記

個人開発しているスマホアプリや将棋AIの開発ネタを中心に書いていきます。

ref localでC#のコードを高速化する

.NET Frameworkと.NET CoreでDictionaryの性能が異なる。

ランダムな整数をキーとして、AddとTryGetValueを5000000回行った結果10回の平均時間(ms)は以下の通りである。

バージョン Add TryGetValue
.NET Framework 4.6 681.8 268.5
.NET Core 2.1 585.9 266.6

TryGetValueはほぼ同じだが、Addは.NET Core 2.1の方が1.16倍速い。

.NET Frameworkと.NET Coreで、Dictionaryの実装にどのような差があるか調べたところ、一つはハッシュエントリの使用方法が改良されていたのと、もう一つはref localを使う変更がされていた。
.NET Framework 4.8のソース
.NET Core 2.1のソース

以下は、ref localについて調べた内容である。

ref local

ref localは、例えば以下のようなコードで、

entries[index].hashCode = hashCode;
entries[index].next = buckets[targetBucket];
entries[index].key = key;
entries[index].value = value;

配列の要素に同じインデックスでアクセスしているところを

ref Entry entry = ref entries[index];
entry.hashCode = hashCode;
entry.next = buckets[targetBucket];
entry.key = key;
entry.value = value;

のように、C++の参照と同じように、参照を示す変数に格納してからアクセスする方法だ。
参考:Ref local reassignment - C# 7.3 specification proposals | Microsoft Docs

なお、配列の要素にref localを使用するには、C# 7.2以上でないないとコンパイルエラーとなるため、.csprojに以下の設定を加える必要がある。(この例では、.NET Core 2.1はC# 7.3に対応しているので、7.3としている。)

  <PropertyGroup>
    <LangVersion>7.3</LangVersion>
  </PropertyGroup>

ref localの効果測定

次に、この部分だけを変更してみて性能にどれだけ差があるか調べてみた。

変更元のソースは、.NET Framework 4.8のソースとした。
https://referencesource.microsoft.com/#mscorlib/system/collections/generic/dictionary.cs

上記の部分の変更前後で測定結果は以下の通りとなった。(変更箇所はAddのみ)

Add TryGetValue
変更前 617.6 262.5
変更後 608.6 261.8

わずか(1.01倍)だが、ref localを使用すると速くなる。

ILDasmで生成されたILを比較してみた。
上記の変更箇所は以下の通りとなっていた。

変更前
    IL_0133: ldloc.0
    IL_0134: stfld System.Int32 MyDictionary1.Dictionary`2/Entry<TKey,TValue>::hashCode
    IL_0139: ldarg.0
    IL_013a: ldfld MyDictionary1.Dictionary`2/Entry<TKey,TValue>[] MyDictionary1.Dictionary`2<TKey,TValue>::entries
    IL_013f: ldloc.2
    IL_0140: ldelema MyDictionary1.Dictionary`2/Entry<TKey,TValue>
    IL_0145: ldarg.0
    IL_0146: ldfld System.Int32[] MyDictionary1.Dictionary`2<TKey,TValue>::buckets
    IL_014b: ldloc.1
    IL_014c: ldelem.i4
    IL_014d: stfld System.Int32 MyDictionary1.Dictionary`2/Entry<TKey,TValue>::next
    IL_0152: ldarg.0
    IL_0153: ldfld MyDictionary1.Dictionary`2/Entry<TKey,TValue>[] MyDictionary1.Dictionary`2<TKey,TValue>::entries
    IL_0158: ldloc.2
    IL_0159: ldelema MyDictionary1.Dictionary`2/Entry<TKey,TValue>
    IL_015e: ldarg.1
    IL_015f: stfld TKey MyDictionary1.Dictionary`2/Entry<TKey,TValue>::key
    IL_0164: ldarg.0
    IL_0165: ldfld MyDictionary1.Dictionary`2/Entry<TKey,TValue>[] MyDictionary1.Dictionary`2<TKey,TValue>::entries
    IL_016a: ldloc.2
    IL_016b: ldelema MyDictionary1.Dictionary`2/Entry<TKey,TValue>
    IL_0170: ldarg.2
    IL_0171: stfld TValue MyDictionary1.Dictionary`2/Entry<TKey,TValue>::value
    IL_0176: ldarg.0
    IL_0177: ldfld System.Int32[] MyDictionary1.Dictionary`2<TKey,TValue>::buckets
変更後
    IL_0133: dup
    IL_0134: ldloc.0
    IL_0135: stfld System.Int32 MyDictionary1.Dictionary`2/Entry<TKey,TValue>::hashCode
    IL_013a: dup
    IL_013b: ldarg.0
    IL_013c: ldfld System.Int32[] MyDictionary1.Dictionary`2<TKey,TValue>::buckets
    IL_0141: ldloc.1
    IL_0142: ldelem.i4
    IL_0143: stfld System.Int32 MyDictionary1.Dictionary`2/Entry<TKey,TValue>::next
    IL_0148: dup
    IL_0149: ldarg.1
    IL_014a: stfld TKey MyDictionary1.Dictionary`2/Entry<TKey,TValue>::key
    IL_014f: ldarg.2
    IL_0150: stfld TValue MyDictionary1.Dictionary`2/Entry<TKey,TValue>::value

変更前は25行、変更後は14行と、ILが短くなっているのが確認できる。

ILDasmについて

なお、ILDasmは.NET FrameworkSDKに付属するが、.NET Coreの場合は、

dotnet tool install --global dotnet-ildasm

でインストールできる。
使う際は、

dotnet-ildasm CSDictionaryTest.dll >CSDictionaryTest.il

のようにして使う。結果は標準出力に出力されるのでリダイレクトするとよい。

まとめ

配列を同じインデックスで複数回アクセスしている場合、ref localを使うとわずかだが速くできる。

測定に使用したソースをGitHubに置いておいた。
GitHub - TadaoYamaoka/CSDictionaryTest
MyDictionary1.csが.NET Framework 4.8のDictionaryを元にしたソース。
MyDictionary2.csが.NET Core 2.1を元にしたソース。
MyDictionary3.csは他の実験したソースなのでこの記事とは無関係。