TadaoYamaokaの開発日記

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

dlshogiのgo nodes対応

少し前にQhapaqさんからもらったプルリクに対応しました。
add go protocol by qhapaq-49 · Pull Request #17 · TadaoYamaoka/DeepLearningShogi · GitHub

go byoyomiで、探索を打ち切らずに時間いっぱいまで探索するようになりました。
go nodesで、固定ノード数まで探索できるようになりました。
(USIオプションのConst_Playoutも継続利用可)

ついでに、今まで探索の打ち切り(最善手が変わる可能性がない場合に打ち切る)は全スレッドで確認していましたが、わずかですが高速化のために一つのスレッドのみで監視するようにしました。

Qhapaqさん、プルリクありがとうございました。

dlshogiにおける思考時間と強さの関係

MCTSと思考時間と強さの関係について、dlshogiのデータが知りたいという要望があったので測定してみた。

測定条件

対戦相手は、最新のやねうら王Ubuntu 18.04のclangでビルドしたもの+水匠2の評価関数で、1000万ノード固定とする。

dlshogiは、最新のソースに、最新のモデル(WCSOC1からR+76くらい)を使用した。


測定環境は、CPU Xeon E5-2698 v4(2.20GHz)、GPU NVIDIA V100、Ubuntu 18.04(Docker)。

水匠2は2スレッド、dlshogiは3スレッド1GPUを使用し、思考時間を変えて測定。

思考時間は、1秒、2秒、3秒、4秒、8秒、16秒とした(8秒、16秒は測定中)。
ポンダーはなし。
256手で引き分け。

強さは、互角局面集を使用して、先後を交互に入れ替え、250戦行った結果で測る。
引き分けも考慮するため、勝率は、勝ちを1ポイント、引き分けを0.5ポイントとして、ポイントの合計/250で計算する。

水匠2は、評価値-10000で投了、dlshogiは、勝率1%で投了する。

eloレーティングは、水匠2のeloレーティングを0、uを勝率として、
\displaystyle
R=400 \log_{10} \frac{u}{(1-u)}
で計算する。

測定結果

思考時間(秒) 勝ち 引き分け 勝率 R
1 87 9 36.6% -95.44
2 132 7 54.2% 29.25
3 158 3 63.8% 98.44
4 165 2 66.4% 118.33

f:id:TadaoYamaoka:20200810210828p:plain

dlshogiの思考時間ごとの探索ノード数は、以下の通り。

思考時間(秒) 探索ノード数(平均) 探索ノード数(中央値)
1 71971 61518
2 140604 121388
3 207204 179994.5
4 270545 232072

考察

dlshogiの思考時間を増やすことで、強さは対数に近い形で伸びているようだ。
もう少し先の形も確認すればはっきりしそうである(8秒、16秒のデータが測定できたら追記する)。

また、測定目的とは異なるが、水匠2の1000万ノードに対して、dlshogiは10万ノード(1/100)くらいで互角になることがわかった。


αβ探索での思考時間と強さの関係

αβ探索での思考時間と強さの関係についても調べた。

対戦相手は、上記と同様に水匠2の1000万ノード固定とする。

測定側は、水匠2 2スレッドで、思考時間を1秒、2秒、3秒、4秒、8秒、16秒とした(8秒、16秒は測定中)。

測定環境は、CPU Xeon Silver 4210 (2.20GHz)を使用した。

測定結果

思考時間(秒) 勝ち 引き分け 勝率 R
1 7 10 4.8% -518.96
2 18 15 10.2% -377.87
3 26 20 14.4% -309.64
4 55 18 25.6% -185.33

f:id:TadaoYamaoka:20200810214546p:plain

水匠2の思考時間ごとの探索ノード数は、以下の通り。
※ログから集計が面倒だったので1局面だけサンプリング

思考時間(秒) 探索ノード数
1 1298863
2 2252468
3 4359210
4 5334843

考察

2スレッドで測定したため、探索ノード数が1000万ノードよりも低すぎて、右側を見ないと対数の関係なのかどうかが見れないデータになっていた。
スレッド数を8にして測定をやり直すことにする。
測定ができたら別途追記する。

まとめ

MCTSの思考時間と強さの関係は、対数の関係になりそうであることがわかった。
探索深さに対してゲーム木のサイズが指数関数的に増えることと関係していると思われる。

AlphaZeroの論文では、αβよりも思考時間に対する強さの伸びが高いことが報告されている。
f:id:TadaoYamaoka:20200810220020p:plain

同じ対数的な伸びでも、伸び方に差がでている。

αβで思考時間を延ばした場合も測定をしようとしたが、今回は測定条件のミスで使えるデータがとれなかった。
測定できたら、別途記事にしたい。

PythonからC#へポインタ渡しする

C#からPythonへポインタ渡しするとは逆に、Python for .NETを使ってPythonからC#へポインタ渡しする方法について紹介する。

Python for .NET

Python for .NETを使用すると、PythonからC#のコードを呼び出すことができる。
Pythonと同じプロセスで、.NETのランタイムを動かすことができ、C#で記述したコードをPythonのモジュールと同じように扱うことができる。

import clr
from System import String
from System.Collections import *

Python for .NETのインストール

pipコマンドで、インストールする。

pip install pythonnet

C#側のコード作成

.NET Core 3.1を使用して、C#のコードを作成する。

クラスライブラリのプロジェクトを作成する。

dotnet new classlib --name torch_sharp

以下の通りC#のコードを記述する。

using System;

namespace torch_sharp
{
    public class Class1
    {
        public unsafe void func(IntPtr data, int size)
        {
            float* array = (float*)data.ToPointer();
            for (int i = 0; i < size; ++i)
            {
                array[i]  = i;
            }
        }
    }
}
解説

Pythonからは、IntPtr型でポインタを受け取り、それをToPointer()でfloat*に型変換して、float型の配列として扱っている。
Python側でもfloat型になっていることを前提にしている。

Python側のコード作成

以下の通りPythonのコードを記述する。

import clr
clr.AddReference('torch_sharp')
from torch_sharp import Class1
from System import IntPtr

import numpy as np

obj = Class1()

data = np.empty(5, dtype=np.float32)
obj.func(IntPtr.op_Explicit(data.ctypes.data), len(data))

print(data)
解説
clr.AddReference('torch_sharp')
from torch_sharp import Class1

この部分で、C#のクラスライブラリをPythonモジュールとしてimportしている。

ビルドしたC#の.dllファイルは、Pythonモジュールと同じディレクトリに配置しておく必要がある。
C#のプロジェクトファイル(.csproj)で、

  <Target Name="CopyDLL" AfterTargets="AfterBuild">
    <Copy SourceFiles="$(OutDir)/torch_sharp.dll" DestinationFolder="../../torch_sharp/" />
  </Target>

のように記述しておくとよい。

Python側からC#にポインタ渡しするデータは、Numpyで作成する。

IntPtr.op_Explicit(data.ctypes.data)

の部分で、Numpyのデータをポインタとして取り出して、C#のIntPtr型に変換している。
ポインタを数値型のままC#に渡して、C#側でIntPtrにしてもよいが、IntPtrで渡した方が32bit、64bitかを気にしなくてよい。

まとめ

PythonからC#へポインタ渡しする方法について紹介した。

C#のアプリケーションで作成したデータを使って、Pythonでモデルを学習したいというようなケースで、C#のデータをシリアライズして、Python側で読み込みたいような場合に、PythonからC#のコードが呼べると便利である。
モデルに入力するためのデータの前処理を行うとデータ量が増えるため、シリアライズデータは加工前の状態にしたいが、Pythonで加工を行うと遅いため、速度を考慮するとC#で加工を行いたい。
しかし、PythonC#の間で、データのマーシャリングが発生すると呼び出しのオーバーヘッドが発生するので、ここで紹介した方法でポインタ渡しをするとオーバーヘッドを抑えられる。

dlshogiのdf-pnのテスト方法

dlshogiのdf-pnだけを動かす手順を書いておきます。

テスト局面追加

testプロジェクトのtest.cppの
「// DfPnテスト」と書いてあるところのmain()の

	vector<string> sfens = {
		// 詰み
		"9/9/+N8/p1p4p1/6p1p/1P7/3k3PP/2+p5L/6+rGK w R2B2G3Sgs3n3l9p 1",
		"1n1g3+Pl/k1p1s4/1ng5p/pSP1p1pp1/1n3p3/P1K3P1P/1P7/9/L1G5L b 2R2BG2SL5Pn 161", // mate 15
		// (略)
	};

の部分にsfenで局面を追加することでテストできます。

test.cppには、main()がたくさんありますが、テストしたいところを「#if 1」にしています。

https://github.com/TadaoYamaoka/DeepLearningShogi/blob/master/test/test.cpp#L359

探索上限変更

上限ノード数は

dfpn.set_max_search_node(1000000);

で変更、深さの上限は、

dfpn.set_maxdepth(29);

で変更できます。

ハッシュサイズ変更

ハッシュの衝突が起きると探索効率が落ちるため、置換表は十分なサイズが必要です。
dfpn.cppの以下の箇所で定数定義しています。

int64_t DfPn::HASH_SIZE_MB = 2048;

https://github.com/TadaoYamaoka/DeepLearningShogi/blob/master/usi/dfpn.cpp#L11

ハッシュの衝突が起きると上書きするため、長手数の詰み探索には向いていません。
長手数の詰み探索には、Small TreeGCとか使う方が正しいです。

ビルド方法

Visual Studioの場合は、ビルド構成をReleaseにしてtestプロジェクトを右クリックしてビルドでビルドできます。
ソリューションのビルドからは外しているため個別にビルドが必要です。

Gccの場合は、testディレクトリにMakefileがあります。

df-pnの関連記事

TLでdf-pnの話が上がっていたので、以前書いた記事のリンクをまとめておきます。

dlshogiのdf-pnの実装は、優越関係、証明駒、反証駒、先端ノードでの3手詰めルーチンを実装していて、ループの問題も処理していますが、対局時の利用に特化しているので、長手数の詰みはノード数上限、深さ上限に引っかかると不詰み扱いになります。
詰め将棋エンジンとしては使えません。

dlshogiでMagic Bitboardを有効にする

やねうら王が飛車と角の利きに、PEXTの代わりにMagic Bitboardを使用することで、Threadripperで高速化できたとのことなので、dlshogiでも高速化できるか試してみた。

dlshogiは合法手生成にAperyのソースを使用しており、AperyはMagic Bitboardを実装していて、HAVE_BMI2を無効にすることで有効化できる(有効にしているとPEXTが使われる)。

自己対局を実行しているPCは、Ryzen Threadripper 3970Xを使用しているので、HAVE_BMI2を無効にしてビルドしなおして、自己対局における局面生成速度を比較した。

現在自己対局では、3枚のGPUで、1GPUあたり3つの探索スレッドと1つの詰み探索スレッドを使用している。
GPUの処理は、効率的なバッチ処理NNキャッシュを使用しており、GPUよりもCPUがボトルネックになっているため、合法手生成の高速化の効果はそのまま局面生成速度に表れると予想している。

比較結果

10000局面を生成した際の局面生成速度を2回測定して比較した。

条件 1回目(NPS) 2回目(NPS) 平均(NPS)
変更前 66.67 66.61 66.64
変更後 75.62 74.44 75.03

平均で、12.6%高速化できた。

まとめ

Ryzen ThreadripperにおけるMagic Bitboardによる高速化は、dlshogiでも同じ効果が得られることがわかった。

大会ではintelのCPUを使用しているので大会での強さには影響なさそうだが、自己対局の生成速度があがるのはすぐにでも恩恵が得られそうである。

C#からPythonへポインタ渡しする

以前にPython for .NETを使用することで、C#Pythonを同一プロセスで動かせることを紹介した。

C#からPythonへの呼び出し時に、引数と戻り値は、自動的にマーシャリングが行われる。
ここで、C#の型からPythonのネイティブな型に変換されるのは、スカラ型とリスト型に限られる。
残りは、C#のクラスをラッピングしたクラスになり、実態はC#側で処理されるオブジェクトになる。

課題

Unityで作成されたC#のプログラムから、PyTorchなどのPythonフレームワークを使用してモデルの学習を行いたい場合を想定する。
このような場合、C#で作成した入力特徴量をPythonに渡す必要がある。

入力特徴量をPyTorchのTensorに変換するには、リスト型か連続したメモリ領域である必要がある。
リスト型にするには、C#からList型で渡せば、自動でマーシャリングされるため、

torch.as_array(input)

とすれば、明示的な変換なしに変換できる。

しかし、入力特徴量は通常サイズが大きく、呼び出しのたびに、List型からPythonのlistへのマーシャリングが発生すると遅くなる。

できれば、連続したメモリ領域をそのまま渡したい。

なお、C#の配列(float[])を渡した場合、Single[]というラッピングされたクラスになるため、torch.as_arrayには入力できない。

ポインタ渡しする方法

Pythonには、ctypesというC言語とのインターフェースのためのライブラリが用意されている。
これを使用することで、ポインタを扱うことができる。

C#にも、オブジェクトのメモリ領域をポインタとして扱う機能がある。
ただし、ポインタとして扱っている間は、ガベージコレクションの対象にならないように、注意が必要である。
具体的には、以下の2つの方法がある。

GCHandle.Allocを使う

以下のコード例のように、GCHandle.AllocにPinnedを指定して、ガベージコレクションの対象にならないようにしたオブジェクトのアドレスを取得するためのハンドルを取得し、AddrOfPinnedObject()でポインタを取得する。
ポインタの使用が終わったらFree()を呼び出す。

using System.Runtime.InteropServices;


var array = new float[] { 1.0f, 2.0f, 3.0f, 4.0f };
var handler = GCHandle.Alloc(array, GCHandleType.Pinned);
var pointer = handler.AddrOfPinnedObject();

// ポインタを使用
python_module.function((Int64)pointer, array.Length);

handler.Free();
fixedを使用する

C#には、ポインタを扱うためのfixedという言語機能がある。

var array = new float[] { 1.0f, 2.0f, 3.0f, 4.0f };
fixed (float* pointer = &array[0])
{
    // ポインタを使用
    python_module.function((Int64)pointer, array.Length);
}                  

Free()が必要ないため、解放漏れの心配がなくなる。
ただし、unsafeを指定することが必須になる。
なお、GCHandle.Allocを使用する場合も、unsafeなことを行っているので、unsafeを指定しておく方が正しいと思われる。

Python側でポインタを扱う

次に、Python側で数値として渡されたポインタを扱う方法について述べる。

上で述べたctypesを使用することで、ポインタを扱うことができるが、PyTorchでは直接ポインタを入力することができたいため、一旦Numpyのオブジェクトにする。
Numpyには、ポインタで渡されたメモリ領域をそのままNumpyのオブジェクトとして使用する方法がある。

import numpy as np
import ctypes

def function(pointer, size):
    array = (ctypes.c_float * size).from_address(pointer)
    data = np.ctypeslib.as_array(array)

ctypesのfrom_address()で、引数で渡された数値のポインタをPOINTER型に変換し、np.ctypeslib.as_array()でNumpyのオブジェクトに変換している。

これを、PyTorchのTensorに変換することができる。

    tensor = torch.as_tensor(data)

ここまでで、C#から渡されたメモリ領域のコピーが発生してないことに注意して欲しい。
つまり、

    tensor[0] = 10

とすると、C#側のarrayの値も変更されることになる。

まとめ

Python for .NETを使用して、C#からPythonへポインタ渡しする方法について紹介した。
ポインタ渡しすることで、マーシャリングやメモリコピーが発生しないため、高速に処理できる。
ポインタを扱えるのは、同一プロセスでランタイムを動かせるPython for .NETのメリットの一つである。


アプリケーションによっては、特徴量の加工の処理が多くなるので、Pythonで処理すると遅くなる。
C#側で加工を行い、そのままTensorにできる状態にして、メモリ領域をポインタで渡せばPython側での加工をなくせるので高速に処理できる。
Tensorは次元の大きい疎ベクトルになることもあるので、ここでマーシャリングが入ってしまうとC#側で加工するメリットが失われてしまう。
その意味でも、ポインタをそのまま渡せるのがベストである。

ソース

github.com