機械学習周りのプログラミング中心。 イベント情報
ポケモンバトルAI本電子書籍通販中

iPadのNeural Engineで将棋AI part08 将棋所MacをTCP通信に対応させる

将来的にはiPad単独でCSAプロトコルを用いて対局サーバに接続することを目指しますが、局面を表示するUIを作る必要があり道のりが長いです。 まずは、Macで動作する将棋エンジン用UIである将棋所Macに通信を仲介してもらうことにします。

将棋所は将棋エンジンと標準入出力でUSIプロトコルを用いて通信します。標準入出力はマシンの中でしか通信できないので、MaciPadを通信させるにはネットワークで中継します。 やることはAWSで将棋エンジンを動作させる場合のssh経由と似ていますが、サーバとクライアントが逆になります。sshでは将棋所側がTCPクライアント、エンジン側がTCPサーバとなります。将棋所で対局開始をクリックしたときにエンジンをsshの機能で起動させることができるのでこの構成になります。一方でiPadのアプリを将棋所のあるMac側から起動して通信を確立するということができないので、将棋所側をTCPサーバ、エンジン側をTCPクライアントにします。将棋所で対局開始をクリックしたときに対局エンジンではなく、TCPサーバを起動します。そして、エンジン側を手動で起動し、その標準入出力をTCPクライアントとして接続します。

サーバ側(将棋所からはエンジンとして見えるもの)は以下のコードで生成します。内容は、ncコマンドを使い、TCPサーバを立ち上げるシェルスクリプトです。

echo -e '#!/bin/sh\nnc -l 8090' > listen.sh
chmod +x listen.sh

将棋所のUI上のエンジン追加で、生成したlisten.shをエンジンとして追加します。listen.shを開いた時点でエンジンとの接続待ち状態になるので、以下のステップでエンジン側を起動します。 まだiPadアプリはできていないので、サンプルとしてMac上でLesserkaiを起動し、あえてTCPクライアントとして通信します。コンソールからLesserkaiがあるディレクトリ上で、以下のコマンドを実行します。

mkfifo ncpipe
nc localhost 8090 <ncpipe | ./Lesserkai >ncpipe

テクニックは、ncコマンドをTCPクライアントとして起動し、その標準出力はパイプでLessekaiの標準入力に、Lesserkaiの標準出力は名前付きパイプでncの標準入力に与えることで双方向通信を実現する点です。

Lesserkaiを接続すればすぐにエンジン登録が完了します。以下、対局時は毎回コンソールから上記コマンドでエンジンを接続します。iPadアプリができたら、そのアプリ上から接続ボタンをタップすることで将棋所に接続することになります。

なお、通信の内容をコンソールに表示する場合は以下のようにします。

nc localhost 8090 <ncpipe | tee /dev/stderr | ./Lesserkai | tee /dev/stderr >ncpipe

以上のように、TCP通信で将棋所とエンジンを分離することができました。USIプロトコルTCP経由で使用するiPadアプリを作れば、UIは将棋所Macに任せることができます。

iPadのNeural Engineで将棋AI part07 iPadの冷却は必要か

前回までで、Core MLによる評価関数モデルの実行ができました。ただしボタンをタップした時に1回だけ実行するというものでした。対局では約1時間にわたり連続稼働する必要がありますので、連続稼働で問題がないかを確認しました。具体的には、発熱によって性能が低下したりクラッシュしたりしないかを確認します。

10分間にわたりモデルの実行を連続して行い、実行速度の時間変化を測定しました。計算に用いるエンジンとしてNeural Engine (ANE)とCPUを試しました(GPUはエラーで動きません)。冷却条件として、ポリエステル製のケースに入った状態(normal)と、ケースを外してサーキュレータの風を直接あてた状態(cooling)を試しました。気温は20度でした。バッチサイズは64で、モデルの実行が完了すると結果は捨ててすぐに次の実行を行います。10秒ごとに、直近10秒間で処理できたサンプル数から計算速度を算出してプロットします。

f:id:select766:20220213094423j:plain
サーキュレータでの冷却。サーキュレータの真上にiPadを置いた。

f:id:select766:20220213094225p:plain
冷却条件による実行速度の時間変化

結果を上図に示します。ANEの場合は1000samples/sec、CPUの場合は300samples/sec程度に収束しました。最初の100秒程度は10%程度高速です。ANEの冷却なしの場合に速度低下が少し遅かったですが、測定を1回しか行っていないため原因はよくわかりません。冷却の有無では速度に差がみられず、いずれの場合も最初の速度より低い速度で収束するということがわかりました。速度の低下は、外部からの冷却では防げない箇所の温度上昇か、温度に関係なくタスクスケジューラが消費電力を抑制しようとしている可能性があります。また、本体の温度を手で触って確認しましたが、わずかしか温度上昇は感じられませんでした。

結論として、iPadを強制空冷しても速度に違いはないことがわかりました。冷却は不要というのが現状の結論です。ただし、評価にNeural Engineを用いつつ、探索でCPUに負荷をかけた場合までは未検証のため、将棋エンジン全体ができた際に再検討する余地があります。

ログ出力の実装

iPad上で動作したログを出力し、PC側で閲覧するための実装の概要を示します。

ログの出力には、Swift標準のログ機能を用いました。ロガーの初期化は以下のように行います。

import os

let logger = Logger(subsystem: "jp.outlook.select766.DlshogiOnCoreML", category: "main")

ログの出力は、

logger.log("hello")

として行うことができます。今回はJSONが出力したかったので、次のような補助関数を実装しました。

func log(_ obj: Any) {
    do {
        // シリアライズできないデータが来ると、 NSInvalidArgumentException が来て
        // これはNSExceptionのサブクラスでありswiftのcatchで処理できず落ちる
        let data = try JSONSerialization.data(withJSONObject: obj, options: [])
        guard let str = String(data: data, encoding: .utf8) else {
            logger.error("Failed to write log")
            return
        }
        logger.log("\(str, privacy: .public)") // 直接Stringは渡せない
    } catch {
        logger.error("Failed to write log")
    }
}

この関数を log(["type": "start", "cu": loadedModelComputeUnits, "bs": batchSize])のようにして呼び出すと、JSON文字列が保存されます。logger.logの引数はただのStringではなく、文字列補間 \()の中身がデフォルトではログに残らないような仕組みになっています。動的な文字列には個人情報などが含まれる想定で保護する目的のようですが、今回は不要なのでprivacy: publicというオプションをつけています。JSONSerialization.dataメソッドの例外はdo-catchでは処理できないObjective-Cベースの例外のようですがObjective-Cの知識がないため放置しています。シリアライズに失敗するようなデータを与えない限り問題ありません。

ログをUSB接続したMacから取り出すには以下のコマンドを実行します。

sudo log collect --device --last 5m

lastオプションはどれぐらいの期間のログを取り出すかを指定します。ログには自作のアプリ以外にもシステム上のすべてのログが含まれています。アプリが終了してもログは残ります(ログレベルに依存)。

実行結果はsystem_logs.logarchiveというディレクトリに書き出されます。ダブルクリックするとMac標準のコンソールアプリで中身を確認できます。

また、以下のコマンドでログをフィルタリングして文字列として出力できます。これをファイルにリダイレクトすることで、Pythonを使った分析ができます。

% log show --archive system_logs.logarchive --predicate '(subsystem IN {"jp.outlook.select766.DlshogiOnCoreML"})' --last 5m
Filtering the log data using "subsystem IN {"jp.outlook.select766.DlshogiOnCoreML"}"
Skipping info and debug messages, pass --info and/or --debug to include.
Timestamp                       Thread     Type        Activity             PID    TTL
2022-02-09 07:49:56.952750+0900 0xc4e3a    Default     0x0                  5462   0    DlshogiOnCoreML: [jp.outlook.select766.DlshogiOnCoreML:main] {"type":"start","cu":"NE","bs":64}
2022-02-09 07:49:57.488087+0900 0xc4e3a    Default     0x0                  5462   0    DlshogiOnCoreML: [jp.outlook.select766.DlshogiOnCoreML:main] {"type":"end","samplePerSec":147.98817574086306,"elapsed":0.43246698379516602,"resultDiff":"Max difference: 0.0040952563","moveDiff":"Max difference: 0.08522034"}
--------------------------------------------------------------------------------------------------------------------
Log      - Default:          2, Info:                0, Debug:             0, Error:          0, Fault:          0
Activity - Create:           0, Transition:          0, Actions:           0

ちなみに、Core ML自体から出力されるログも存在します。

neural-engine/os-log.md at master · hollance/neural-engine · GitHub

log show --archive system_logs.logarchive --predicate '(subsystem IN {"com.apple.espresso","com.apple.coreml"}) && (category IN {"espresso","coreml"})' --info --debug --last 5m

以下のような警告が出ていました。実行結果はあっているので問題ありません。

2022-02-08 21:00:02.369336+0900 0xbb6f5    Debug       0x0                  4853   0    DlshogiOnCoreML: (Espresso) [com.apple.espresso:espresso] Kernel validation warning u1_1_1 (convolution) @ 2: HW utilization loss: HW may round up computation of output sizes to multiple of 8 or 16, but the output size is 9x9
2022-02-08 21:00:02.371119+0900 0xbb6f5    Debug       0x0                  4853   0    DlshogiOnCoreML: (Espresso) [com.apple.espresso:espresso] Kernel validation warning 126 (activation) @ 8: ACTIVATION_SIGMOID and ACTIVATION_TANH can be well approximated. Do precision check to make sure
2022-02-08 21:00:02.371183+0900 0xbb6f5    Debug       0x0                  4853   0    DlshogiOnCoreML: (Espresso) [com.apple.espresso:espresso] Kernel validation warning x.5 (convolution) @ 10: HW utilization loss: HW may round up computation of output sizes to multiple of 8 or 16, but the output size is 9x9
2022-02-08 21:00:02.371264+0900 0xbb6f5    Debug       0x0                  4853   0    DlshogiOnCoreML: (Espresso) [com.apple.espresso:espresso] Kernel validation warning 139 (activation) @ 11: ACTIVATION_SIGMOID and ACTIVATION_TANH can be well approximated. Do precision check to make sure
2022-02-08 21:00:02.373080+0900 0xbb6f5    Debug       0x0                  4853   0    DlshogiOnCoreML: (Espresso) [com.apple.espresso:espresso] Kernel validation warning input.93 (reshape) @ 87: Invalid input blob shape for resize/unflatten

ここまでで、Core MLで将棋の評価関数を動作させる技術検証ができました。ここからは将棋アプリを開発していきます。まずは合法手生成からです。

iPadのNeural Engineで将棋AI part06 Core MLでバッチサイズ固定モデル

前回は、実行時にバッチサイズを動的に指定できるモデルを作成し実行しました。今回は、バッチサイズを固定したモデルを作成し、より高いパフォーマンスを発揮するかどうか検証します。どんなバッチサイズでも受け入れるモデルよりも、バッチサイズ16しか受け入れないと最初から指定したほうが最適化できる可能性があるためです。結果として、うまく動きませんでした。

生成方法

前回の可変バッチサイズのモデルよりも単純です。単にバッチサイズを定数で与えればOKです。

batch_size = 64 # 変換時に定数で指定
traced_model = torch.jit.trace(si_model, torch.zeros(batch_size, 119, 9, 9))
ct_input_x = ct.TensorType(name='x', shape=(batch_size, 119, 9, 9))
mlmodel = ct.convert(traced_model, inputs=[ct_input_x])

実行結果

生成したモデルをアプリに組み込んで、iPad上での実行時間を測定しました。バッチサイズごとに別のモデルファイルになるので、バッチサイズを変えるたびにモデルファイルを差し替えてビルドしなおすことになります。実行時間を下表に示します。表内の数値は、各実行環境での実行時間[秒]です。初回実行は遅いので、同じ入力での2回目の実行時間を測定しています。Xとなっている個所は、実行時エラーが発生したものです。バッチサイズは、4の累乗およびプラスマイナス1を指定しました。2の累乗ちょうどだとパフォーマンスが悪いという可能性も想定したためです。結果は、バッチサイズ5以下ではNE (Neural Engine)がうまく動作しており他の環境より高速です。一方で15以上では、NEやGPUがCPUと同等または遅いという結果になりました。さらにバッチサイズを増やすと、エラーが発生して動作しませんでした。バッチサイズ可変モデルでNE環境、バッチサイズ16での実行時間が0.033秒だったので、バッチサイズ可変モデルを使うほうが明らかに高速です。 Core MLはモバイル端末での推論を念頭に実装されていると考えられるため、バッチサイズ1以外の動作は十分テストされていない可能性があります。

バッチサイズ CPU GPU NE
1 0.034 0.028 0.0068
3 0.061 0.051 0.050
4 0.065 0.055 0.058
5 0.070 0.071 0.064
15 0.115 0.114 0.118
16 0.090 0.120 0.118
17 0.096 0.129 0.124
63 0.233 0.239 0.767
64 0.205 0.212 0.800
65 0.210 0.217 0.813
255 0.743 0.789 X
256 0.712 0.856 X
257 0.717 0.828 X
1023 3.00 X X
1024 2.94 X X
1025 X X X

実行時エラーはこのような内容がXcodeに表示されたのち、強制終了しました。

2022-01-31 20:34:33.236848+0900 DlshogiOnCoreML[1015:307097] [espresso] [Espresso::handle_ex_plan] exception=Error creating IOSurface
2022-01-31 20:34:33.246093+0900 DlshogiOnCoreML[1015:307097] [coreml] Error plan build: -1.
2022-01-31 20:34:33.262153+0900 DlshogiOnCoreML[1015:307564] [ServicesDaemonManager] interruptionHandler is called. -[FontServicesDaemonManager connection]_block_invoke

結論としては、バッチサイズ可変モデルを生成したうえで、実行時には固定のバッチサイズを指定する(バッチサイズを変えるたびに初回の実行時間が増えるので)というのが正解のようです。バグが疑われる挙動のため、モデルのわずかな違いや今後のバージョンアップで結果が大きく異なる可能性があります。今回は数回の実行しかしていませんが、今後、長時間にわたってCore MLを動作し続けても問題が生じないか検証していきます。

iPadのNeural Engineで将棋AI part05 Core MLでバッチサイズ可変モデル

前回は、バッチサイズ1の入力のみを受け付けるCore MLモデルを生成し、実行できることを確認しました。 DNNの実行効率(1サンプル当たりの所要時間)は一般的にバッチサイズが大きいほど向上します。 そのため、DNNを効率よく実行するにはバッチサイズ2以上の入力を受け付けるようなCore MLモデルを生成したいです。 その手段として、(1)バッチサイズ256のような固定のバッチサイズ(>1)を受け入れるモデルを生成する、(2)実行時に任意のバッチサイズを受け入れるモデルを生成するという2通りが考えられます。 コンピュータ将棋の場合、固定のバッチサイズに満たないサンプル数でモデルを実行したい場合もある(探索開始時にルート局面だけを評価する場合等)ため、(2)が実現できると効率が良いのでこれを実現できるか試しました。 任意のバッチサイズの入力を受け取って処理できるモデルをここでは「バッチサイズ可変モデル」と呼ぶことにします。

結果の要約は以下のようになります。

  • バッチサイズ1024を与えたときの処理時間が約1秒
    • 1サンプル当たりの計算量は1GFLOPs。Neural Engineの処理速度はおおむね1TFLOPSと見積もれる。
    • 将棋のゲーム木探索に用いることを考えると、1000NPSでありかなり低め。もっと計算量が小さいモデルが良い。
  • わずかな条件差でCore ML内部で実行時エラーが起きやすく扱いづらい
  • バッチサイズを切り替えるたびに余分な時間がかかるため非効率

バッチサイズ可変モデルの生成

バッチサイズ可変モデルは、coremltoolsでの変換時にオプションを与えることで実現できます(ライブラリのマニュアル)。ライブラリの機能上、バッチサイズだけでなく画像サイズなどを可変にする場合にも使用できます。

ライブラリに対して、モデルの入力のどこが変化する可能性があるか教える必要があります。その柔軟性に応じて区分があります。

  1. 入力しうる形状を列挙する方法(列挙タイプ)
  2. ある軸の長さの範囲を指定する方法(範囲タイプ)

列挙タイプでは、形状を可変にできる入力テンソルが1個という制約があります。しかしdlshogiのモデルではx1, x2という2つの入力があり、それぞれ[バッチサイズ, 62, 9, 9][バッチサイズ, 57, 9, 9]という形状のため両方を可変にしなければならずそのままでは実装できません。そのため、入力変数を1個にまとめて、モデル内部で分割してx1, x2として扱うようにしました。コードは以下のようになります。

class SingleInputModel(torch.nn.Module):
  def __init__(self, model):
    super().__init__()
    self.model = model
  def forward(self, x):
    return self.model(x[:, 0:62, :, :], x[:, 62:119, :, :])

f:id:select766:20220125200557p:plain
元のモデル(netron.appで可視化)
f:id:select766:20220125200538p:plain
入力統合後のモデル

列挙タイプの場合は、形状を表すタプルのリストをEnumeratedShapesクラスに与えます。

input_shape = ct.EnumeratedShapes(shapes=[(b, 119, 9, 9) for b in [1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024]])
ct_input = ct.TensorType(name='x', shape=input_shape)
mlmodel = ct.convert(traced_model, inputs=[ct_input])

範囲タイプの場合は、形状を変化させたい軸にRangeDimクラスを用います。ここで、RangeDimの引数を空にすれば任意の数を受け取ることを表現できます。今回はそこまでの自由度は必要ないため十分な大きさとして1024を与えました。

input_shape = ct.Shape(shape=(ct.RangeDim(1, 1024), 119, 9, 9))
ct_input = ct.TensorType(name='x', shape=input_shape)
mlmodel = ct.convert(traced_model, inputs=[ct_input])

列挙タイプのメリットは、指定された各形状にあわせた最適化がなされるとドキュメントに記載があります。

バグの回避

これで単純に変換できれば簡単だったのですが、coremltoolsのバグを踏んでしまうようでValueError: Cannot add const [is35, 2187]というエラーが発生しました。調査したところ、flattenオペレータの変換部分で失敗していることがわかりました。dlshogiモデル内でのflattenオペレータの使用状況を調べ、出力の形状を決め打ちで与えるオペレータで上書きしてやることにより、バグを回避することに成功しました。(簡単そうに書いていますが調査はかなりの知識を要します)

# dlshogiモデルに特化した内容であり汎用性はないので注意
from coremltools.converters.mil.frontend.torch.torch_op_registry import register_torch_op
from coremltools.converters.mil.frontend.torch.ops import _get_inputs
from coremltools.converters.mil.mil import Builder as mb

@register_torch_op(override=True)
def flatten(context, node):
    inputs = _get_inputs(context, node)

    x = inputs[0]
    dims = [-1, 2187] # 決め打ち設定。reshapeStaticオペレータが出力されるが、長さ-1の次元は許容される

    reshape = mb.reshape(x=x, shape=dims, name=node.name)
    context.add(reshape)

以上のようなテクニックを組み合わせ、Core ML形式のモデルを得ることができました。colab notebook

実行

前の章で生成したモデルをSwift側で実行する方法ですが、バッチサイズ固定の場合と違いはありません。与えるMLMultiArrayに形状情報が含まれているので、バッチサイズ等の明示的な情報付与は不要です。変換の都合で入力変数がx1, x2の2つからxの1つに変わったので、そこは書き換えます。

guard let pred = try? model.prediction(x: sampleIO.x) else {
    msg = "Error on prediction"
    return
}

簡易ベンチマーク

実行速度を簡易的にベンチマークしました。あくまで1回の試行結果です。入力バッチサイズを変えると初回の実行が遅くなる場合がある(後述)のため、2回目以降の処理時間を示します。

範囲タイプのモデルの処理時間を示します。

環境 バッチサイズ 処理時間[s]
NE 1 0.007
NE 8 0.025
NE 16 0.033
NE 64 0.086
NE 256 0.26
NE 1024 0.99
CPU 1 0.041
CPU 8 0.090
CPU 64 0.22
CPU 1024 3.15

処理時間は、model.predictの前後でDate()により得た時刻の差分で測定しています。「環境」は、NE=Neural Engine (computeUnits = .all)、GPU(computeUnits = .cpuAndGPU)、CPU(computeUnits = .cpuOnly)の3種類です。GPUを指定すると、以下のような実行時エラーが発生したため計測できませんでした。NE, CPUではともに正常動作しているため、モデルの誤りではなくCore ML内部の実装のバグではないかと思われます。

validateComputeFunctionArguments:745: failed assertion `Compute Function(TARR_elementwise_add_broadcast_f16_pack4): missing buffer binding at index 1 for p2[0].'

変換元となったdlshogiのモデルは10ブロック(畳み込み層20回)、192チャンネルのものです。計算量は1.099GFLOPsです(算定方法:畳み込み層と全結合層のみ。積和演算を2回の演算とみなす。)。どのようなバッチサイズでも、Neural EngineのほうがCPUより高速です。Neural Engine、バッチサイズ1024のときが最も高速で、1.137TFLOPSの演算能力が得られることがわかりました。将棋AIとして探索と組み合わせることを考えるとバッチサイズは16程度が現実的で、この場合で533GFLOPSとなります。モバイル端末としては十分な能力なのですが、将棋AIとしては1000NPS程度しか出ないというのは探索不足で弱いと予想されます。モデルの精度低下と引き換えに、もう少し計算コストが小さいモデルを用いてNPSを上げたほうが強くなる可能性が高いです。

model.predictを呼ぶたびにバッチサイズが異なる入力を与えても正常動作しますが、バッチサイズを変えた初回の実行は、2回目以降の実行より時間がかかるようです。例えば、Neural Engineでアプリ起動直後からバッチサイズ1024→1023→1024→1024という与え方をすると、処理時間が1.64秒→1.42秒→1.40秒→0.99秒というように変化しました。メモリの確保しなおしなどのオーバーヘッドが生じているものと思われます。そのため、サンプル数が少ない場合でも、ダミーデータで埋めて固定バッチサイズで実行したほうが良い可能性が高いとわかりました。さらに、CPUではバッチサイズを変化させた直後のほうが速いという不思議な挙動がありました。バッチサイズ8→8→1→1という与え方をすると、0.046秒→0.079秒→0.015秒→0.037秒という変化でした。なぜかバッチサイズ1にした直後が高速です。熱的な要因を疑って各実行の間を数十秒あけてみましたが変化はありませんでした。

列挙タイプのモデルも測定を試みましたが、Neural Engineで内部エラーが発生し実行できませんでした。CPUでは正常動作しましたが、範囲タイプの時と処理時間の差は(簡易測定の範囲では)見られませんでした。ちなみに、バッチサイズ1,2,4をEnumeratedShapesに与えて変換していても、その途中にあるバッチサイズ3の入力をするとエラーになります。自動的にパディングして大きいほうに合わせてくれるというような機能はありません。

以上のように、バッチサイズ可変モデルは一応作れますが、入力バッチサイズを変化させた直後の処理が遅いこと、不可解なエラーが発生することから使いづらいという結果になりました。今後、バッチサイズ16等で固定したモデルを生成してみて動作確認します。

※実験後にiPadOS 15.3にアップデートしました。範囲タイプのモデルを試した結果、速度、エラーとも本記事の内容と変化はありませんでした。

iPadのNeural Engineで将棋AI part04 Core MLでdlshogiの評価関数を動かす

iPad上でCore MLを用いてバッチサイズ1でモデルを動作させ、計算結果があっているかどうかの確認までを行います。

検証用データの生成

DNNではモデルがエラーなく動作したように見えても、入力データの与え方が間違っていたり、変換ツールのバグを引いたりして結果が間違っているということがよくあります。 そのため、変換元のモデル(PyTorch)にあるテンソルXを入力したときの出力Yを保存しておき、変換後のモデル(Core ML)でも入力Xに対する出力がYと一致するかどうかを検証する必要があります。 検証用データはcolab notebook上で作成します。

コードは以下のようになります。ポイントは以下の通り。

  • 入力データは乱数より本来想定される入力を使うべきなので、棋譜データを使います。
  • 前回変換したモデルはバッチサイズ1しか対応していませんが、将来バッチサイズを増やすことを想定し、1024サンプル分(バッチサイズ32×32回反復)のデータを出力します。
  • Swiftでnumpyのファイルフォーマットを読み込めないので、数値を生のバイナリ形式で出力します。
from dlshogi.common import *
from dlshogi.network.policy_value_network import policy_value_network
from dlshogi import serializers
from dlshogi import cppshogi
from dlshogi.data_loader import DataLoader

# 棋譜データ
train_dataloader = DataLoader(DataLoader.load_files(["/content/drive/My Drive/ShogiAIBookData/dlshogi_with_gct-001.hcpe"]), 32, device, shuffle=False)

# モデルロード
model = policy_value_network("resnet10_swish", add_sigmoid=True)
model.set_swish(False)#swishをx*sigmoid(x)で計算するモード
serializers.load_npz("/content/drive/My Drive/ShogiAIBook/model/model_resnet10_swish-072", model, False)
model.eval()

# モデルに棋譜データを与えて結果を収集
x1s = []
x2s = []
moves = []
results = []
n_batch = 32
for x1, x2, move, result, value in train_dataloader:
  # x1 = [32, 62, 9, 9], x2 = [32, 57, 9, 9]
  pred_move, pred_result = model(x1, x2)
  # pred_mode: [32, 2187], pred_result: [32, 1]
  x1s.append(x1.detach().numpy())
  x2s.append(x2.detach().numpy())
  moves.append(pred_move.detach().numpy())
  results.append(pred_result.detach().numpy())
  if len(x1s) >= n_batch:
    break

# 入出力をファイルに保存
with open("/content/drive/My Drive/ShogiAIBook/model/SampleIO.bin", "wb") as f:
  for arys in [x1s, x2s, moves, results]:
    d = np.concatenate(arys, axis=0).tobytes()
    f.write(d)

iPadアプリの作成

ここからはMacXcode上での作業になります。iPhoneアプリ開発の入門書通りの箇所は説明しないので、適宜本を読んでください。私は「絶対に挫折しない iPhoneアプリ開発「超」入門 第8版 【Xcode 11 & iOS 13】 完全対応」(SBクリエイティブ)で入門しました。

  • アプリのテンプレートは、iOS→App
  • 言語はSwift

コードはこちらに上げてあるので、全文を見たい場合は参照してください。モデル・検証用データはおいていませんので、自前でcolab notebookを使って変換してください。Swift初心者なので、イケてないやり方が混じってるかもしれませんのでご了承ください。

github.com

プロジェクトへのCore MLモデルの追加

前回作成したDlShogiResnet10Swish.mlmodelファイルをダウンロードし、FinderからXcodeのプロジェクトファイル一覧のところにD&Dします。ダイアログが出たらそのままOKで閉じます。これだけでモデルの追加は完了です。

検証用データの読み込み

まず、先ほど生成したSampleIO.binをモデルと同様にXcodeプロジェクトに追加します。すると、アプリ内でこのファイルが使用可能になります(ファイルがコンパイルされるのかそのままアプリ内にコピーされるのかは拡張子で決まっている?)。以下のステップにより、ファイルの中身をFloat型の配列として読むことができます。

guard let url = Bundle.main.url(forResource: "SampleIO", withExtension: "bin") else {
    fatalError("SampleIO.bin not found")
}
guard let data = try? Data(contentsOf: url) else {
    fatalError("Failed to read SampleIO.bin")
}
let arr = data.withUnsafeBytes {
    Array(UnsafeBufferPointer(start: $0.baseAddress!.assumingMemoryBound(to: Float.self), count: $0.count / MemoryLayout<Float>.size))
}

Core MLのテンソルは、MLMultiArray型で表現されます(画像を扱う場合には、それに特化した型もあります)。これは以下のように生成します。生成時に、形状とデータ型を指定します。

guard let mmArray = try? MLMultiArray(shape: [1, 62, 9, 9], dataType: .float32) else {
    fatalError("Cannot allocate MLMultiArray")
}

MLMultiArrayの要素単位の読み書きはmmArray[0] = 1.2のように普通に配列のように扱って記述することも可能なのですが、NSNumber型を用いた変換が入るためか遅いです。そのため、以下のコードでより効率が良いポインタ型で扱います。

let mmRawPtr = UnsafeMutablePointer<Float>(OpaquePointer(mmArray.dataPointer))

// オフセット付きコピーの例
for i in 0..<size {
    mmRawPtr[i] = arr[ofs + i]
}

SampleIO.binには複数のテンソルをつなげて書き込んであるため、適宜オフセットを指定して読み込んでいきます。コード全文はこちらを参照ください。

モデルの読み込み

アプリ内でのモデルのロード方法です。プロジェクトにモデルを追加すると、そのファイル名に対応したDlShogiResnet10Swishクラスが生成されています。ContentViewクラス内で、ボタンをクリックした際のイベントハンドラloadModelでモデルを読み込みます。ここで、config.computeUnitsに指定する値により、使用されるアクセラレータが変わります。allはNeural Engine/GPU/CPUすべて、cpuAndGPUGPUとCPU、cpuOnlyはCPUのみでの実行となります。すべての処理がNeural Engine上でできるとは限らず、実行計画はCore ML任せとなります。それから、動作確認用のサンプルデータもgetSampleIOで読み込んでおきます。

struct ContentView: View {
    @State var model: DlShogiResnet10Swish?
    @State var sampleIO: SampleIO?
    
    func loadModel() {
        let config = MLModelConfiguration()
        config.computeUnits = .all//デバイス指定(all/cpuAndGPU/cpuOnly)
        model = try? DlShogiResnet10Swish(configuration: config)
        sampleIO = getSampleIO(batchSize: 1)
    }

モデルの実行

モデルの実行は非常に簡単です。model.prediction()に入力テンソルMLMultiArray形式で与えるだけです。PyTorchだとGPU上にあるモデルにはGPU上にあるテンソルを入力する必要がありますが、Core MLではそのようなデータ移動処理は不要です。引数名はモデル変換時のct.TensorType(name='x1', shape=(1, 62, 9, 9))のように指定した値から来ています。モデルの出力テンソルが2個あり、pred.move, pred.resultとしてそれぞれMLMultiArrayを取り出すことができます。move, resultという名前は変換のところでct.utils.rename_feature(spec, move_name, "move")のように指定した値から来ています。最後に、独自に実装したテンソル比較用の関数isArrayCloseで計算結果があっているかサンプルデータと比較します。

guard let pred = try? model.prediction(x1: sampleIO.x1, x2: sampleIO.x2) else {
    msg = "Error on prediction"
    return
}
let moveDiff = isArrayClose(expected: sampleIO.move, actual: pred.move)
let resultDiff = isArrayClose(expected: sampleIO.result, actual: pred.result)

iPad実機での実行

作ったアプリをまずはMac内のシミュレータで実行して、動作を確認できました。MacにはNeural Engineは搭載されておらず、エミュレーションもされないのでNeural Engineでの動作を確認するにはiPad実機での実行が必要です。以下のような初期設定が必要ですが、Core MLを使うからといって特別な操作はなく入門書通りです。

  • まずMac-iPadを初めて繋いだら、「このコンピュータを信頼しますか」と聞かれるのでOK
  • Xcode内、左側のペインでプロジェクトを選択、中央のペインでSigning & Capabilitiesを選択、中央でTeamを設定。iPadに設定されているApple IDと関連づいていないといけない。
  • iPad設定アプリ内、一般→VPNとデバイス管理→デベロッパApp→Apple Development: <メールアドレス>→Apple Development: <メールアドレス>を信頼

実行結果

作成したアプリを実行し、結果があっているかを確認しました。浮動小数点演算が絡む以上、結果の完全一致はしないので誤差を許容して比較します。比較はnumpy.isclose相当の手段を実装しました。e: 期待する値(PyTorchのモデルの出力)、a: 実際の値(Core MLの出力)として、diff = abs(e-a), tol = atol + rtol * abs(e)を計算、diff > tolならエラーとなります。実験してみると、アクセラレータにより、誤差が大きく変わることがわかりました。

computeUnits = .cpuの場合、rtol=1e-3, atol=1e-5でエラーになりませんでしたが、.cpuAndGPU.allの場合はエラーとなりました。.allの場合、e=0.0390に対してa=0.0569というかなり大きな誤差が出る要素がありました。エラーが出ないようにするにはrtol=1e-1, atol=5e-2というかなり大きな許容範囲を設定(およそ10%の誤差を許容)する必要がありました。今回試した1サンプルでの結果なので、あらゆるサンプルでこの範囲の誤差に収まる保証はありません。大多数の結果はそこまでずれませんが、思いのほかずれる場合があるというのは留意が必要です。アクセラレータ指定を変えると結果が変わるので、確かにアクセラレータが使用されていると考えられます。

以上のように、Core ML形式に変換したdlshogiのモデルを実行して結果が正しいこと、またNeural Engineが使用されていることを確認しました。今後、バッチサイズ2以上の処理を実装し、どの程度の速度で評価関数の実行が可能かを調べていきます。現在の変換方法ではバッチサイズ1以外のMLMutiArrayを与えるとエラーになるため、変換時にバッチ処理を考慮する必要があります。

iPadのNeural Engineで将棋AI part03 dlshogiの評価関数をCore ML形式に変換

iPad上でdlshogiの評価関数であるDNNモデルを動作させるには、Appleが提供しているCore ML APIを使用します。 本記事では、モデルをAppleの開発環境へ取り込むための変換作業を解説します。

モデルの変換

dlshogiのモデルはPyTorch形式のため、これをCore ML専用の形式に変換する必要があります。 そのためには、Pythoncoremltoolsパッケージを使用します。

https://coremltools.readme.io/docs/pytorch-conversion

coremltoolsはPyTorchまたはTensorFlowのモデルをCore ML形式に変換する機構が含まれています。ONNX形式からの変換機構は一度実装されたもののdeprecatedになっているようです。ONNXさえあればPyTorchとTensorflow両方のサポートをしなくてよいと思うのですが、どうしてこういうポリシーなのかはわかりません。

モデルの変換は以下のステップで行います。

  1. PyTorch上でモデルをロードする。
  2. モデルをTorchScriptに変換する。
  3. TorchScriptをCore ML形式に変換する。
  4. 出力変数の名前を書き替える。
  5. モデルを保存する。

モデルの変換を行うcolab notebookはこちらです。ポイントを説明します。

PyTorch上でモデルをロードする

import torch
from dlshogi.common import *
from dlshogi.network.policy_value_network import policy_value_network
from dlshogi import serializers
from dlshogi import cppshogi

model = policy_value_network("resnet10_swish", add_sigmoid=True)
model.set_swish(False)#swishをx*sigmoid(x)で計算するモード
serializers.load_npz("./model/model_resnet10_swish-072", model, False)
model.eval()#評価用モード(Batch Normalization等の挙動設定)

モデルをロードします。ポイントはmodel.set_swish(False)です。これはdlshogiのモデル固有の設定となりますが、PyTorchにはSwishという活性化関数のオペレータが実装されていますが、これを使うと対応するオペレータがCore MLになく変換できないため同等の機能をx * sigmoid(x)というより単純な計算の組み合わせで実行するモードです。

モデルをTorchScriptに変換する

モデルの計算手順を、TorchScriptという形式に変換します。これは、Pythonインタプリタがないような組み込み環境などでも実行できるようなPython依存性の低いフォーマットです。

x1 = torch.zeros(1, 62, 9, 9)
x2 = torch.zeros(1, 57, 9, 9)
traced_model = torch.jit.trace(model, (x1, x2))

変換にはモデルを一度実行して、実行されたオペレータを記録するという形でTorchScriptを構築します。実行のために入力テンソルx1, x2を与えています。形状だけが必要で、テンソルの内容は影響ありません。

TorchScriptをCore ML形式に変換する

ここからcoremltoolsを使います。以下のコードでCore ML形式への変換ができます。入力テンソルの形状とTorchScriptを与えます。

import coremltools as ct

ct_input_x1 = ct.TensorType(name='x1', shape=(1, 62, 9, 9))
ct_input_x2 = ct.TensorType(name='x2', shape=(1, 57, 9, 9))
mlmodel = ct.convert(traced_model, inputs=[ct_input_x1, ct_input_x2])

注意ですが、このやり方ではバッチサイズ1での実行しかできませんので、多数の局面評価をバッチ処理するには効率が悪いです。今後改良します。

出力変数の名前を書き替える

前のステップで出力されたモデルそのままでも使用できるのですが、出力変数名がvar_577のような実装依存の値になり、Swiftコード内でこの名前を指定することになるので不便です。以下のコードで、出力変数名を好きな名前(ここではmoveresult)に書き換えます。

spec = mlmodel.get_spec()
move_name, result_name = mlmodel.output_description
ct.utils.rename_feature(spec, move_name, "move")
ct.utils.rename_feature(spec, result_name, "result")
mlmodel = ct.models.MLModel(spec)

モデルを保存する

モデルをファイルに保存します。

mlmodel.save("./model/DlShogiResnet10Swish.mlmodel")

ファイル名がそのままSwiftコードからモデルを呼び出す際のクラス名になるため、名前をパスカルケースでつけています。

Core ML以外にNeural Engineを使用する方法はないか

Core MLが機械学習専用チップであるNeural Engineを使用する唯一の方法となります。iPhone/iPad向けのTensorflow LiteもCore MLのラッパーとなっています。GPGPUのように自前でシェーダを記述することはできないようです。

次回は、今回出力したモデルをiPad上で動かすサンプルアプリを作ります。

iPadのNeural Engineで将棋AI part02 iPadを有線LANに接続

WCSCでは、対局サーバと将棋AIが入ったパソコンの間を有線LAN (Ethernet)で接続する必要があります。iPadを有線LANに接続する手段を検証しました。

※本記事は将棋AIの話題はなく、WCSC特有の課題への対応手段を説明します。

接続対象の環境

WCSCの有線LAN環境は(2019年時点)対戦サーバと、全参加者のパソコンをスイッチングハブを介して接続する形態でした。ルータによるインターネットとの接続はない閉じた環境で、DNSDHCPもありません。参加者は、事前に指定されたIPアドレスをパソコンに設定する必要があります。このような環境への接続を試みました。

ハードウェア

端末はiPad 第9世代(iPadOS 15.2)です。接続ポートはLightning端子のみです。なお、iPad ProはUSB-C端子なので今回の手順は使えません。 結論は、Lightning端子を一旦USBに変換し、これにUSB接続の有線LANアダプタを接続するという形態で実現できました。 Lightning-USB変換は「Lightning - USB 3カメラアダプタ」(Apple製、MK0W2AM/A)、USB-有線LAN変換は「ETX3-US2」(I-O DATA製)を使用しました。前者は今回のために新規購入(約5000円)、後者は過去のWCSC参加のために買ったものです。iPad対応とは書かれていませんが、現物合わせをした結果使用できました。ETX3-US2は通信速度が最大100Mbpsですが、将棋をやる分には全く問題ないでしょう。 接続すると下の写真のようになります。

f:id:select766:20220121195531j:plain
ETX3-US2の接続

「Lightning - USB 3カメラアダプタ」にはUSB端子のほかにLightning充電端子もついており、USB機器を使用しながら充電もできます。今回の目的では長時間にわたり高負荷を掛けるので、充電器が接続できることは重要です。 なお、USB-有線LAN変換に「EDC-GUA3H-W」(ELECOM製)は使えませんでした。接続しても反応がありませんでした。 確実に接続できるApple公式のアクセサリに「Belkin Ethernet + Power Adapter with Lightning Connector」があります。ただし12000円とお高めなので、今回はLightning-USB変換器だけ購入し、既存のUSB-有線LAN変換器が使えるか試しました。

設定

iPad画面上での設定を行います。

まず、機内モードに設定してWiFiの接続を切ります。それから、各アダプタをiPadに接続します。

接続すると、設定アプリ内にEthernetという項目が現れます。インターフェース「ETX3-US2」をタップします。

f:id:select766:20220121200640p:plain
Ethernetの項目

インターフェース設定画面が表示されます。「IPを構成」をタップします。

f:id:select766:20220121200727p:plain
インターフェース設定画面

「手動」をタップ、「IPアドレス」および「サブネットマスク」に必要事項を記入(WCSCでは、主催側からメールで指示された値)し、保存をタップします。

f:id:select766:20220121200817p:plain
IP構成画面

これで接続完了です。

f:id:select766:20220121200944p:plain
接続完了

実際に、SafariからLAN内に立てたHTTPサーバに接続できることを確認しました。

f:id:select766:20220121201005p:plain
LAN内HTTPサーバへの接続確認

以上のように、iPadをWCSCで必要な有線LAN接続に対応させられることがわかりました。