TadaoYamaokaの開発日記

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

Unity Barracudaでdlshogiのモデルを推論する

Unity Barracudaは、Unity用のマルチプラットフォームに対応した推論パッケージである。

ONNXのモデルをロードでき、WindowsでもAndroidでもiOSでも(WeGLでも?)同じコードでディープラーニングモデルの推論ができる。

Barracudaで、dlshogiのモデルが扱えるか使えるか試してみた。

UnityにBarracudaをインストール

UnityでBarracudaを使えるようにするには、Package Managerで、Barracudaを追加する必要がある。
Unity Repositoryから検索できないため、Add Package from git URLに、「com.unity.barracuda」と入力して追加する必要がある。
参考:unity3d - Can't find Barracuda package in Unity Registry - Stack Overflow

モデルをAssetsに追加

AssetsにModelというフォルダを作り(フォルダを作らなくても良い)、dlshogiのモデル(.onnx)をエクスプローラからドラッグ&ドロップして追加する。

追加したモデルを選択してインスペクタで確認すると警告が表示された。
f:id:TadaoYamaoka:20220216003243p:plain

output_policyの次元も間違った値になっている。

原因を調べたところ、torch.flattenを使うと、警告がでるようだ。
torch.flattenをやめて、view(-1, N)にすると、警告がでなくなった。
ただし、Nの部分は、数値リテラルで入力する必要がある。
出力の次元はPythonで計算で求めているので、変数で指定したいが数値リテラルでないとonnxにできなくなる。

モデル学習

torch.flattenをviewに変えただけなので、dlshogiのモデルのパラメータはロードできるが、今回はテスト用にモデルサイズを小さくして、floodgateの棋譜を学習させた。

推論のコード

HierarchyのルートにC#コードを追加して、以下の通りモデルをロードして推論を行うコードを記述した。
入力特徴量作成は、dlshogiのスクリプトで初期局面の入力特徴量を作成してバイナリで出力して、Assets/Resorcesに追加して、ファイルから読み込むようにしている。

注意点として、モデルはNCHW形式でも、入力Tensorは、NHWCにしないといけない点だ。
入力特徴量をファイル出力する際に、transposeで次元の変換を行った。

using System;
using System.Collections;
using System.Collections.Generic;
using Unity.Barracuda;
using UnityEngine;

public class GameManager : MonoBehaviour
{
    public NNModel modelAsset;

    // Start is called before the first frame update
    void Start()
    {
        Model runtimeModel = ModelLoader.Load(modelAsset, true);

        var worker = WorkerFactory.CreateWorker(WorkerFactory.Type.Compute, runtimeModel);

        TextAsset textAsset1 = Resources.Load<TextAsset>("input1");
        int inputSize1 = textAsset1.bytes.Length / 4;
        float[] inputData1 = new float[inputSize1 * 2]; // batchsize2
        for (int i = 0; i < inputSize1; ++i)
        {
            inputData1[i] = BitConverter.ToSingle(textAsset1.bytes, i * 4);
            inputData1[inputSize1 + i] = BitConverter.ToSingle(textAsset1.bytes, i * 4);
        }
        Tensor inputTensor1 = new Tensor(2, 9, 9, 62, inputData1);

        TextAsset textAsset2 = Resources.Load<TextAsset>("input2");
        int inputSize2 = textAsset2.bytes.Length / 4;
        float[] inputData2 = new float[inputSize2 * 2];
        for (int i = 0; i < inputSize2; ++i)
        {
            inputData2[i] = BitConverter.ToSingle(textAsset2.bytes, i * 4);
            inputData2[inputSize2 + i] = BitConverter.ToSingle(textAsset2.bytes, i * 4);
        }
        Tensor inputTensor2 = new Tensor(2, 9, 9, 57, inputData2);

        worker.Execute(new Dictionary<string, Tensor> { { "input1", inputTensor1 }, { "input2", inputTensor2 } });

        Tensor outputTensorPolicy = worker.PeekOutput("output_policy");
        Tensor outputTensorValue = worker.PeekOutput("output_value");

        int batchsize = 2;
        float[] outputPolicy = outputTensorPolicy.data.Download(new TensorShape(2187 * batchsize));
        float[] outputValue = outputTensorValue.data.Download(new TensorShape(batchsize));

        var legalLabels = new Dictionary<string, int>
        {
            {"1g1f", 5},
            {"2g2f", 14},
            {"3g3f", 23},
            {"4g4f", 32},
            {"5g5f", 41},
            {"6g6f", 50},
            {"7g7f", 59},
            {"8g8f", 68},
            {"9g9f", 77},
            {"1i1h", 7},
            {"9i9h", 79},
            {"3i3h", 25},
            {"3i4h", 115},
            {"7i6h", 214},
            {"7i7h", 61},
            {"2h1h", 331},
            {"2h3h", 268},
            {"2h4h", 277},
            {"2h5h", 286},
            {"2h6h", 295},
            {"2h7h", 304},
            {"4i3h", 187},
            {"4i4h", 34},
            {"4i5h", 124},
            {"6i5h", 205},
            {"6i6h", 52},
            {"6i7h", 142},
            {"5i4h", 196},
            {"5i5h", 43},
            {"5i6h", 133},
        };
        for (int i = 0; i < batchsize; ++i)
        {
            int offset = 2187 * i;
            float max = 0.0f;
            foreach (var kv in legalLabels)
            {
                float x = outputPolicy[offset + kv.Value];
                if (x > max)
                {
                    max = x;
                }
            }
            // オーバーフローを防止するため最大値で引く
            float sum = 0.0f;
            foreach (var kv in legalLabels)
            {
                float x = Mathf.Exp(outputPolicy[offset + kv.Value] - max);
                outputPolicy[offset + kv.Value] = x;
                sum += x;
            }
            // normalize
            foreach (var kv in legalLabels)
            {
                outputPolicy[offset + kv.Value] /= sum;
            }
            foreach (var kv in legalLabels)
            {
                Debug.Log($"{kv.Key} {outputPolicy[offset + kv.Value]}");
            }

            Debug.Log(outputValue[i]);
        }

        inputTensor1.Dispose();
        inputTensor2.Dispose();
        outputTensorPolicy.Dispose();
        outputTensorValue.Dispose();
        worker.Dispose();
    }

    // Update is called once per frame
    void Update()
    {
    }
}

モデル設定

エディタ側で、modelAssetに、Assetsに追加したモデルを設定する。

結果

実行すると、推論結果は、コンソールログに以下のように表示される。

1g1f 0.03669669
2g2f 0.3327546
3g3f 0.003403991
4g4f 0.001507061
5g5f 0.001972405
6g6f 0.0003284561
7g7f 0.4425911
8g8f 0.000323784
9g9f 0.0374275
1i1h 3.537552E-05
9i9h 1.628024E-05
3i3h 0.01439433
3i4h 0.02409641
7i6h 0.008480824
7i7h 0.009563878
2h1h 0.0001176913
2h3h 0.0001388098
2h4h 0.0004411801
2h5h 0.00565653
2h6h 0.01059533
2h7h 0.004915519
4i3h 4.113196E-05
4i4h 1.68125E-05
4i5h 0.002398505
6i5h 0.0005775181
6i6h 2.587938E-05
6i7h 0.04750725
5i4h 0.0005738597
5i5h 8.232462E-05
5i6h 0.01331916
0.5261418
(2バッチ目は省略)

dlshogiでDebugMessage=true、Softmax_Temperature=100にして、表示した結果とほぼ一致することを確認した。
浮動小数点の精度の違いで少しだけ値がずれるが誤差の範囲である。

警告の出たモデルで推論

警告が表示された元のdlshogiのモデルでは、推論結果は正しく出力されなかった。
モデルの修正は必須のようである。

まとめ

Unity Barracudaでdlshogiのモデルが推論できることを確認した。
確認したのはWindow上だが、AndroidiOSでも同じコードで実行できるはずである。

少し躓いたのは、torch.flattenが使えなかったことであるが、torch.flattenをviewに変えるだけで解決できた。

推論速度は別途測定したい。

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

前回、dlshogiにおける思考時間と強さの関係を調べた。
今回は、水匠5の思考時間と強さの関係を調べた。

同一系統のソフト間では、レーティング差が大きくでるため、基準ソフトを思考時間1秒のdlshogiとした。

測定条件

水匠5は8スレッドで、思考時間4秒と32秒で比較した。
dlshogiは、V100x1GPU、2スレッド、思考時間1秒とした。
棋譜が重複しないように、dlshogi互角局面を使用した。

測定結果

   # PLAYER                          :  RATING  ERROR  POINTS  PLAYED   (%)  CFS(%)    W    D    L  D(%)
   1 suisho5-8th-byoyomi32000        :   142.8   29.6   258.0     350    74     100  246   24   80     7
   2 dlshogi-1gpu-2th-byoyomi1000    :   -38.1   18.1   301.0     702    43     100  280   42  380     6
   3 suisho5-8th-byoyomi4000         :  -104.7   28.0   143.0     352    41     ---  134   18  200     5

White advantage = 12.03 +/- 13.37
Draw rate (equal opponents) = 6.46 % +/- 0.98

思考時間を4秒から32秒に8倍にすることで、R+247.5になった。
思考時間2倍あたり、R+82.5である。

dlshogiの場合、思考時間を8倍にすると、R+214.8で、2倍あたりR+71.6だったので、測定条件も違うため一概に比較できないが、レーティングの伸び方はそれほど離れてはいないようである。

なお、水匠同士では、以下のようになる。

   # PLAYER                      :  RATING  ERROR  POINTS  PLAYED   (%)  CFS(%)    W    D    L  D(%)
   1 suisho5-8th-byoyomi32000    :   162.8   21.6   297.0     344    86     100  267   60   17    17
   2 suisho5-8th-byoyomi4000     :  -162.8   21.6    47.0     344    14     ---   17   60  267    17

White advantage = 34.84 +/- 22.00
Draw rate (equal opponents) = 31.07 % +/- 3.80

思考時間8倍で、R+325.6、2倍あたりR+108.5である。
やはり同系統だと大きな値が付く。

まとめ

floodgateでR4000以上のソフト同士の対局で、思考時間を2倍にするとレーティングがどれくらい上昇するか調べた。
結果、測定条件が一致していないため単純比較はできないが、dlshogiではR+71.6、水匠5では、R+82.5という結果になった。

思考時間2倍にした割には、意外と伸びは小さいようである。
つまり、ハードウェア性能が2倍になってもレーティングの伸びはこの程度になるということである。

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

以前に、dlshogiにおける思考時間と強さの関係を調べたことがあった。
tadaoyamaoka.hatenablog.com
tadaoyamaoka.hatenablog.com

思考時間と強さは対数の関係にあり、思考時間を2倍にすると水匠2(1000万ノード固定)を基準に、平均でR+84伸びるという結果だった。

48先生のfloodgateでの調査では、プレイアウト数が1024倍になると、R+1300になっている。2倍あたりではR+130になる。
bleu48.hatenablog.com


最新のモデルでどうなるか気になったため、測定した。
強さは、水匠5(やねうら王の最新のソースビルド)を基準にした。

測定条件

思考時間5秒の水匠5を基準に、dlshogiの思考時間1秒と思考時間8秒で比較した。
水匠5は10スレッド、dlshogiは、V100×1、3スレッドで測定した。
棋譜が重複しないように、dlshogi互角局面を使用した。

測定結果

   # PLAYER                          :  RATING  ERROR  POINTS  PLAYED   (%)  CFS(%)    W    D    L  D(%)
   1 dlshogi-1gpu-3th-byoyomi8000    :   140.7   31.2   251.5     329    76     100  240   23   66     7
   2 suisho5-10th-byoyomi5000        :   -66.6   18.7   247.0     661    37      65  225   44  392     7
   3 dlshogi-1gpu-3th-byoyomi1000    :   -74.1   29.4   162.5     332    49     ---  152   21  159     6

White advantage = 27.47 +/- 14.37
Draw rate (equal opponents) = 7.27 % +/- 1.02

思考時間1秒から8秒(8倍)にすることで、R+214.8になった。
思考時間2倍あたりでは、R+71.6になる。

以前の測定結果よりは小さい値で、48先生のfloodgateでの調査と比較すると半分程度である。

強くなると、思考時間を増やしてもレーティングの伸びは小さくなるという仮説が成り立つかもしれない。
ただし、測定条件や対局相手がそれぞれ異なるため、その影響によるものかもしれない。

思考時間を16秒、32秒と変えて測定するともう少し関係が見えるが、測定に時間がかかるので別途気が向いたら測定してみる。

追記

上記の実験では基準を水匠にしているが、dlshogi同士だともっと差が開く。
ただし、同系列の対局によるレーティングの測定は、floodgateのレーティングとかなり乖離するためあまり信用できない。

   # PLAYER                          :  RATING  ERROR  POINTS  PLAYED   (%)  CFS(%)    W    D    L  D(%)
   1 dlshogi-1gpu-3th-byoyomi8000    :   217.1   31.1   309.0     335    92     100  297   24   14     7
   2 dlshogi-1gpu-3th-byoyomi1000    :  -217.1   31.1    26.0     335     8     ---   14   24  297     7

White advantage = 11.51 +/- 17.40
Draw rate (equal opponents) = 15.70 % +/- 3.32

【勉強ノート】 VAEによる画像生成

ほぼ個人メモです。

PytorchでVAEによる画像生成を試した。
GitHubでスターが多い以下のリポジトリを使用した。
GitHub - AntixK/PyTorch-VAE: A Collection of Variational Autoencoders (VAE) in PyTorch.

以下に、手順のメモを示す。

手順

データセット(CelebA dataset)ダウンロード

CelebAは、20万を超える有名人の画像を含む大規模な顔属性データセットである。
Googleドライブからダウンロードできる。
gdownを使用して、IDを指定してダウンロードする。

pip install gdown
gdown --id 1m8-EBPgi5MRubrm6iQjafK2QMHDBMSfJ
ダウンロードしたzipを解凍
unzip celeba.zip -d Data
unzip Data/celeba/img_align_celeba.zip -d Data/celeba
yamlを編集

バニラVAEを試すため、configs/vae.yamlを編集する。
GPU IDが1になっているため、

gpus: [1]

gpus: [0]

に変更する。

訓練実行
python run.py -c configs/vae.yaml

実行結果の最終行は以下のように表示される。

Epoch 99: 100%|███████| 2856/2856 [01:25<00:00, 33.33it/s, loss=0.0195, v_num=1]
結果確認

結果は、logsに出力される。
loss等のグラフは、tensorboardで確認できる。

tensorboard --logdir logs/VanillaVAE/version_0

画像生成のサンプルは、
logs/VanillaVAE/version_0/Samples
pngでエポック毎に出力されている。

100エポック学習した時点のサンプル画像
f:id:TadaoYamaoka:20220205193341p:plain

なお、サンプル画像を見ると、数エポックで学習できており、100エポックも学習する必要はなかったようだ。

画像生成

学習済みモデルを使用して、1枚ずつ画像を生成するためのスクリプトは用意されていないため、自分でスクリプトを記述する必要があった。
以下は、Jupyter Notebookでの実行例である。

%matplotlib inline
import matplotlib.pyplot as plt

import torch
ckpt = torch.load('logs/VanillaVAE/version_0/checkpoints/last.ckpt')

import yaml
config = yaml.safe_load(open('configs/vae.yaml'))

from models import *
model = vae_models[config['model_params']['name']](**config['model_params'])

from experiment import VAEXperiment
experiment = VAEXperiment(model, config['exp_params'])

experiment.load_state_dict(ckpt['state_dict'])

device = torch.device("cpu")

img = experiment.model.sample(1, device).squeeze().permute(1,2,0).detach().numpy()
plt.imshow(img)

以下のように、生成した画像が表示される。
f:id:TadaoYamaoka:20220205193700p:plain

img = experiment.model.sample(1, device).squeeze().permute(1,2,0).detach().numpy()
plt.imshow(img)

をもう一度実行すると、別の画像が生成される。
f:id:TadaoYamaoka:20220205193845p:plain

モーフィング

潜在空間でサンプリングした2点を線形補完することで、モーフィングを行う。

# モーフィング
z = torch.empty(10, experiment.model.latent_dim)
src = torch.randn(2, experiment.model.latent_dim)
for i in range(10):
    alpha = i / 9
    z[i] = (1 - alpha) * src[0] + alpha * src[1]

samples = experiment.model.decode(z)

imgs = samples.permute(0,2,3,1).detach().numpy()

plt.figure(figsize=(12,8))
for i in range(10):
    plt.subplot(1, 10, i + 1)
    plt.imshow(imgs[i])

f:id:TadaoYamaoka:20220206142135p:plain

演算

データセットに付けられたラベルを利用して、ラベルに対応する潜在空間のベクトルを算出して、潜在空間でベクトルの加算を行う。

笑顔のラベル(Smiling)に対して、ベクトルの加算を行った結果は以下の通り。

from dataset import VAEDataset
data = VAEDataset(**config["data_params"], pin_memory=len(config['trainer_params']['gpus']) != 0)
data.setup()

smiling_label = data.train_dataset.attr_names.index('Smiling')

# 潜在空間でのベクトルを算出
pos_sum = torch.zeros(experiment.model.latent_dim)
neg_sum = torch.zeros(experiment.model.latent_dim)
pos_count = 0
neg_count = 0
experiment.model.eval()
for x, target in train_dataloader:
    pos_sel = target[:,smiling_label] == 1
    neg_sel = target[:,smiling_label] == 0
    mu, log_var = experiment.model.encode(x)
    z = experiment.model.reparameterize(mu, log_var).detach()
    pos_sum += torch.sum(z[pos_sel], 0)
    neg_sum += torch.sum(z[neg_sel], 0)
    pos_count += pos_sel.sum()
    neg_count += neg_sel.sum()

pos_avr = pos_sum / pos_count
neg_avr = neg_sum / neg_count
vec = pos_avr - neg_avr

# サンプリングした点に対して潜在空間でベクトルを加算
z = torch.empty(10, experiment.model.latent_dim)
src = torch.randn(experiment.model.latent_dim)
for i in range(10):
    alpha = i - 5
    z[i] = src + alpha * vec

samples = experiment.model.decode(z)

imgs = samples.permute(0,2,3,1).detach().numpy()

plt.figure(figsize=(12,8))
for i in range(10):
    plt.subplot(1, 10, i + 1)
    plt.imshow(imgs[i])

f:id:TadaoYamaoka:20220206171308p:plain

5番目がサンプリング点から生成した画像で、左に行くほど負のベクトルを大きく、右に行くほど正のベクトルを大きくしている。
右に行くほど顔の表情が笑顔になっていることが確認できる。

KL情報量を利用した時間制御

先日の記事で、dlshogiの手数ごとのポリシーと訪問回数のKL情報量を調査した。

KL情報量が高い局面は、探索がより重要になる局面と考えられるため、KL情報量を時間制御に使用することを考えた。

現状の時間制御

現状のdlshogiは、以下の式で1手にかける時間を計算している。

\displaystyle
\frac{残り時間}{14 + max(30, 30 - ply)} + inc

1手にかける残り時間で次善手が最善手を超えることがない場合、そこで探索を打ち切る。
ただし、21手以降で最善手の訪問回数が次善手の訪問回数の1.5倍以内の場合、もしくは、最善手と次善手の勝率が逆の場合は、探索時間を2倍に延長する。

KL情報量を利用した時間制御

1手にかける基本時間は、現状と同じ式で計算する。

探索中にKL情報量を計算し、基本時間にKL情報量をN倍した値を乗じる。
Nは、先日の調査で50手から100手でKL情報量が平均0.8くらいだったため、現状の探索延長した場合の2倍より少し大きいくらいになる値として4とした(要調整)。
また、基本時間から変化が大きくなりすぎないように、基本時間の0.5倍~4倍の範囲になるようにクリップする。

強さの測定

持ち時間5分1手2秒加算の条件で、強さを測定した。

   # PLAYER          :  RATING  ERROR  POINTS  PLAYED   (%)  CFS(%)    W    D    L  D(%)
   1 kld             :    48.9   29.1   153.0     256    60      96  132   42   82    16
   2 master          :     4.8   27.0   132.5     260    51      99  113   39  108    15
   3 suisho5-10th    :   -53.7   27.7   100.5     256    39     ---   84   33  139    13

※dlshogiは、V100×1、2スレッド
※水匠5は10スレッドで、強さ調整のため持ち時間を1.5倍にしている。

KLD情報量を利用したバージョンの方が、R+44.1だけ強くなっている。

ソフト間の勝率は以下の通り。

kld vs master: 60-46-24 (55.4%)
Black vs White: 69-37-24 (62.3%)
kld playing Black: 38-15-12 (67.7%)
kld playing White: 22-31-12 (43.1%)
master playing Black: 31-22-12 (56.9%)
master playing White: 15-38-12 (32.3%)

master vs suisho5-10th: 67-48-15 (57.3%)
Black vs White: 69-46-15 (58.8%)
master playing Black: 37-16-12 (66.2%)
master playing White: 30-32-3 (48.5%)
suisho5-10th playing Black: 32-30-3 (51.5%)
suisho5-10th playing White: 16-37-12 (33.8%)

kld vs suisho5-10th: 72-36-18 (64.3%)
Black vs White: 65-43-18 (58.7%)
kld playing Black: 41-12-10 (73.0%)
kld playing White: 31-24-8 (55.6%)
suisho5-10th playing Black: 24-31-8 (44.4%)
suisho5-10th playing White: 12-41-10 (27.0%)

現状のdlshogi(master)に対しても、水匠5に対しても勝率が上がっている。

消費時間の比較

現状

f:id:TadaoYamaoka:20220131231240p:plain

KL情報量を利用したバージョン

f:id:TadaoYamaoka:20220131231243p:plain


KL情報量を利用したバージョンは、序盤での消費時間が少なくなり、50手から100手の消費時間が多くなっており、100手から200手では消費時間が少なくなっている。
狙い通り、より中盤に時間を使うようになっている。

300手を超えたあたりで、KL情報量が大きくなり消費時間が急に多くなる局面があった。
局面を調べると、相入玉の局面で、KL情報量が大きくなる傾向があるようである。
クリップする範囲は、もう少し調整をした方が良さそうである。

なお、KL情報量は以下のようになっていた。
f:id:TadaoYamaoka:20220131232604p:plain

水匠5の消費時間

参考として、水匠5の消費時間は以下の通りであった。
前述した通り持ち時間は1.5倍にしている。
f:id:TadaoYamaoka:20220131231923p:plain

150手あたりまでは、比較的均等に時間を使っているようである。

まとめ

KL情報量を利用して時間制御を行う新しい手法を検討した。
KL情報量を利用することで、より中盤に時間を使うようになり、持ち時間5分1手2秒加算の条件で、現状よりも強くなることが確認できた。
KL情報量に乗じる値は勘で決めた値のため、まだ調整の余地があるため、もう少し調整したい。
基本時間の式も、50手くらいにピークがくるように調整した方が良さそうである。

手数ごとのポリシーと訪問回数のKL情報量

山下さんがAobaZeroのKL情報量を調べていて、気になったのでdlshogiでも調べてみた。
KL情報量は、2つの確率分布の差異を測る指標である。
ニューラルネットワークで予測したポリシーと、MCTSで探索した結果のルートノードでの訪問回数のKL情報量を測ることで、どれくらいポリシーの予測が外れているかが確認できる。

KL情報量

Pをポリシーの確率分布、Qを訪問回数の確率分布とすると、KL情報量は以下の式で表される。
\displaystyle
KLD(P, Q) = \sum_i P(i) \log{\frac{P(i)}{Q(i)}}

測定結果

連続対局を行い、手数ごとのKL情報量の平均を算出した結果は、以下の通り。

f:id:TadaoYamaoka:20220129221826p:plain

seabornで95%信頼区間も表示している。

sns.relplot(x='ply', y='kld', kind='line', data=df, aspect=1.5)

考察

開始局面付近での、KL情報量は小さく、探索しなくてもポリシーのみで正確な手が予測できていることがわかる。
50手くらいまで右肩あがりにKL情報量が上がり、50数手でピークになる。

50手から100手くらいでKL情報量が高い状態が続き、中盤に難解な局面があることがわかる。

100手を超えると徐々にKL情報量が下がっていき、終局に近づくほど予測しやすくなっている。

200手を超えると分散が大きくなっているのは、サンプルが少ないことが原因と考えられる。

サンプル数

f:id:TadaoYamaoka:20220129235712p:plain

まとめ

dlshogiのポリシーと訪問回数のKL情報量を調べた。
50手から100手付近がKL情報量が高くなり、中盤に探索が重要な局面が多いことが確かめられた。

中盤により探索の時間をかけた方がよいと言えそうで、時間制御にKL情報量を利用すると効果的である可能性がある。
別途、KL情報量を時間制御に利用する方法を検証したい。

将棋AI実験ノート:入力特徴量の転送量削減

以前にCUDAマルチストリームに対応した際、8GPUだと、CPU-GPU間の帯域がボトルネックとなり、NPSが上がらない課題があることがわかった。
対策として、FP16で転送することを検討し、NPSが平均で18.4%向上することを確認した。

GPU数とNPS

GPU数とNPSの関係を調べたところ以下の通りであった。
f:id:TadaoYamaoka:20220110202547p:plain

FP32ではGPUを増やしてもNPSが線形に伸びていないことが確認でき、CPU-GPU間の転送がボトルネックになっていることがわかる。

転送量の削減

年末にDiscordでやりとりしている中で、CPU-GPU間の転送がボトルネックを解消するアイディアとして、入力特徴量をbitで送る案をMizarさんが提案し、さっそく実装して、NPSが大幅に向上すると報告があった。

現状のdlshogiでは、入力特徴量は、9×9の2値画像×119チャンネルを、浮動小数(FP32)で転送している。
各画素の値は、0か1のため、情報量は1bitで表せる。
また、持ち駒の数など特徴量は、9×9の画像のすべての画素を0か1にしているため、チャンネルを1bitで表すことができる。
そこで、画素またはチャンネルの値を1bitで転送し、GPU側で浮動小数にすることで、転送量を削減できる。

dlshogiでも、この転送量の削減を実装し、NPSがどれくらい上がるか測定してみた。

GPU側で展開する処理

転送したデータの各bitを、浮動小数の画像に展開する処理は、CUDAのプログラムで実装する。
画素の浮動小数は、FP16とする。

Mizarさんの実装では、NVRTCを使用して、実行時にCUDAのプログラムをコンパイルしていたが、初期化の処理が増えるため、事前にnvccでコンパイルしてリンクするようにした。

また、Mizaerさんの実装では、全てのバッチのデータをbit単位で詰めて転送量を減らしているが、バッチ単位でデータクリアする処理が煩雑になるため、バッチ単位では、バイト境界をまたがないように実装した(転送量は少し増えるがCPU側の処理は軽くなり、既存のコードと処理を共通化しやすくなる)。

CUDAプログラム(unpack.cu)
#include "unpack.h"

constexpr int features1_size = sizeof(features1_t) / sizeof(DType) / SquareNum;
constexpr int features2_size = sizeof(features2_t) / sizeof(DType) / SquareNum;

__global__ void unpack_features1_kernel(char* p1, short* x1) {
	int tid = blockIdx.x * blockDim.x + threadIdx.x;

	int p1_offset = sizeof(packed_features1_t) * 8 * blockIdx.x + threadIdx.x * 81;
	int x1_offset = tid * 81;
#pragma unroll
	for (int i = 0; i < 81; ++i) {
		int j = p1_offset + i;
		// p1[j / 8] >> (j % 8)で下位1bitに設定する値を持ってくる
		// 下位1bitのマスクを行い、符号を負にすることで1の場合1byteの全bitを1にする
		// 0x3c00と論理積を取ることでfloat16の1.0にする
		x1[x1_offset + i] = (-(short)((p1[j >> 3] >> (j & 7)) & 1)) & 0x3c00;
	}
}

__global__ void unpack_features2_kernel(char* p2, short* x2) {
	int tid = blockIdx.x * blockDim.x + threadIdx.x;

	int j = sizeof(packed_features2_t) * 8 * blockIdx.x + threadIdx.x;
	short v = (-(short)((p2[j >> 3] >> (j & 7)) & 1)) & 0x3c00;

	int x2_offset = tid * 81;
#pragma unroll
	for (int i = 0; i < 81; ++i) {
		x2[x2_offset + i] = v;
	}
}

void unpack_features1(const int batch_size, packed_features1_t* p1, features1_t* x1, cudaStream_t stream)
{
	unpack_features1_kernel<<<batch_size, features1_size, 0, stream>>>((char*)p1, (short*)x1);
}

void unpack_features2(const int batch_size, packed_features2_t* p2, features2_t* x2, cudaStream_t stream)
{
	unpack_features2_kernel<<<batch_size, features2_size, 0, stream>>> ((char*)p2, (short*)x2);
}

nvccを使用するVisual Studioのプロジェクト設定

.cuをnvccでコンパイルするように、Visual Studioのプロジェクト設定を行う。

NVIDIA CUDA Visual Studio Integrationを使うと、特に設定しなくても.cuをコンパイルできるが、ビルド環境構築の前提条件が増えてしまうため、カスタムビルドツールの設定で対応した。

.cuのプロパティで、カスタムビルドツールを選択し、
f:id:TadaoYamaoka:20220110205519p:plain

全般のコマンドラインと出力ファイルに以下のように設定する。
f:id:TadaoYamaoka:20220110205550p:plain

コマンドライン

Releaseビルド

"$(CUDA_PATH_V11_1)\bin\nvcc.exe" --use-local-env -ccbin "$(VC_ExecutablePath_x86_x64)" -x cu --keep-dir $(Platform)\$(TargetedSDKConfiguration) -maxrregcount=0  --machine 64 --compile -cudart static -DFP16 -DNDEBUG -D_CONSOLE -D_UNICODE -DUNICODE -Xcompiler "/EHsc /W3 /nologo /O2 /Fdx64\Release\vc142.pdb /FS /MT" -o "$(Platform)\$(TargetedSDKConfiguration)\%(Filename)%(Extension).obj" "%(FullPath)"

Debugビルド

"$(CUDA_PATH_V11_1)\bin\nvcc.exe" --use-local-env -ccbin "$(VC_ExecutablePath_x86_x64)" -x cu -G --keep-dir $(Platform)\$(TargetedSDKConfiguration) -maxrregcount=0  --machine 64 --compile -cudart static -g -DFP16 -D_DEBUG -D_CONSOLE -D_UNICODE -DUNICODE -Xcompiler "/EHsc /W3 /nologo /Od /Fdx64\Debug\vc142.pdb /FS /Zi /RTC1 /MTd" -o "$(Platform)\$(TargetedSDKConfiguration)\%(Filename)%(Extension).obj" "%(FullPath)"
出力ファイル
$(Platform)\$(TargetedSDKConfiguration)\%(Filename)%(Extension).obj

Makefile

LinuxMakefileでは、.cuをnvccコマンドでコンパイルする定義を追加する。

obj/unpack.cu.o: unpack.cu
	nvcc -x cu -I../cppshogi -maxrregcount=0 --machine 64 --compile -cudart static -DFP16 -DNDEBUG -Xcompiler "-Ofast" -o obj/unpack.cu.o unpack.cu

測定結果

floodgateの棋譜からサンプリングした100局面で、1秒探索した際のNPSの統計は以下の通り。
各局面を10回測定して平均値を使用した。
モデルサイズは15ブロック224フィルタ。

RTX3090、2スレッド
master FP16 pack FP16/master pack/master
平均 29417 29689 30518 100.8% 103.7%
中央値 30238 30726 31278 101.6% 103.9%
最大値 31593 32261 33232 103.1% 105.7%
最小値 18337 17976 19077 96.8% 99.6%

※masterが現在のdlshogi
※FP16が転送時の浮動小数をFP16にしたバージョン
※packが今回実装した画素をbitで転送して、GPU側で浮動小数に展開したバージョン
※比は、同じ局面での比

RTX3090では、平均で3.9%NPSが向上した。
Mizarさんの報告では10ブロックで10%弱、5ブロックで12%NPSが向上しているが、15ブロックでは、GPUの処理の方がボトルネックとなるようである。

A100x8、GPUあたり4スレッド
master FP16 pack FP16/master pack/master
平均 287561 346906 392338 120.7% 136.6%
中央値 289233 351813 398223 120.9% 136.6%
最大値 339627 394866 438219 149.8% 179.2%
最小値 221239 257580 291222 102.7% 109.3%

A100x8では、平均で36.6%NPSが向上した。
RTX3090 1枚に比べて、大幅に向上している。
8GPUではCPU-GPU間の帯域がボトルネックになっていたことがわかる。

まとめ

入力特徴量のCPU-GPU間の転送量を削減することで、8GPUの場合にNPSが平均で36.6%向上することがわかった。
今回の実験によっても、8GPUではCPU-GPU間の帯域がボトルネックとなっていたことが裏付けられた。

ニューラルネットワークの出力についても、現在は非合法手の指し手の確率も転送しているが、合法手をbitで表現して送って、転送量を減らす案がある。
こちらも別途検証してみたい。

また、CUDAマルチストリームを使用した場合についても別途検証したい。

ソース

feature/pack_featuresブランチにプッシュしている。
GitHub - TadaoYamaoka/DeepLearningShogi at feature/pack_features
※既存処理とのifdefによる条件分けは未実装