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

【ポケモンバトルAI本】技術書典13 頒布予定【オンラインのみ・第4巻に追加コンテンツ】

技術書典13は2022年9月10日から9月25日まで開催される技術系同人誌の頒布イベントです。当サークル「ヤマブキ計算所」はオンラインマーケットのみ参加です。

新刊はありませんが、2020年9月に発行したポケモンバトルAI本第4巻に、約30ページの加筆を行いました。4巻で扱った1vs1バトル向けアルゴリズムの、3vs3バトルへの拡張がテーマです。すでに購入済みの方も、技術書典マイページの本棚から再ダウンロードすることで加筆済みのPDFデータを入手できます。

techbookfest.org

2022年1月発行の単発本「Deep Learning Code Golfやってみた」も引き続き頒布しております。

techbookfest.org

他にも面白い本が多数ありますので、一度オンラインマーケットを覗いてみてください。

techbookfest.org

iPhoneでテキストを音声入力してHTTPサーバにPOSTする

WebベースのTODOアプリを試作していたのですが、ブラウザ上のフォームからキーボードで入力するだけでなく、iPhoneの音声入力を用いて手がふさがっているときでも入力を行えるようにできないか方法を考えていました。その結果、「ショートカット」というiPhoneの標準アプリで実現できました。この記事でやり方を記載します。

「ショートカット」を使うと、以下の流れが簡単に実現できます。一連の操作がすべて音声で完結します。

  • iPhoneがスリープ状態の時に、「Hey Siri タスク」と呼びかけることで自作のショートカットを起動する
    • 「タスク」は自作のショートカットの名前。好きな名前を指定できる。
  • ショートカット内で音声入力によりテキストを受け取る
  • 受け取ったテキストを含むJSONを作成し、HTTPベースのAPIに対してPOSTする
  • API呼び出し結果を音声で読み上げる

iOSバージョンは15.5です。

ショートカットの作り方

今回タスクを登録するAPIは、curlだと以下のようにして呼び出せる自作のものです。認証用のトークンとタスク名を含んだJSONをPOSTし、成功({"result":true})/失敗({"error":"some message"})がJSONで返ってきます。これを、ショートカットを用いて呼び出すことが目標です。

curl -X POST -H "Content-Type: application/json" -d '{"token":"xxx", "title":"hello from curl"}' https://******.cloudfunctions.net/addTask

「ショートカット」アプリを起動し、右上の「+」ボタンで新規ショートカットを作成します。

ショートカット一覧画面

ショートカット作成画面です。上部の「ショートカット名」のところに任意の名前を付けます。この名前はSiriでショートカットを呼び出すときに使うので、短いカタカナにするのが良いと思います。画面下部の「App及びアクションを検索」を用いて「アクション」を追加していきます。

ショートカット作成画面

操作は直感的なのであまり悩まず進められると思います。以下の順序でアクションを並べれば完成です。

ショートカット全体1/2
ショートカット全体2/2
POST部分詳細

アクション名の一覧を示します。

アクション名一覧

ポイントは、

  • 音声入力したテキストを変数に設定する
  • 「URLの内容を取得」で「本文を要求」を設定し、フィールドの値に先ほど生成した変数を指定する。なお、tokenの内容は変数ではなく文字列をそのまま書いています。
  • API呼び出し結果がJSONで得られるので、「辞書」に関する機能で解釈します。成功時は「完了」と音声で読み上げ、失敗時はエラーメッセージそのものを読み上げます(自分で使うアプリなのでこれで十分)。

Siriへの明示的な登録は不要で、ショートカット作成画面の上部に書いてある通り、「Hey Siri、タスク」と呼びかければ起動します。また、画面右下の再生ボタンでこの場で実行することも可能です。

(備考) APIの作り方

HTTP POSTを受け付けてデータベースに記録するAPIですが、FirebaseのFunctions機能を使って実装しています。詳細は割愛しますが、こんな感じの実装になっています。

import * as functions from "firebase-functions";
import * as admin from "firebase-admin";

admin.initializeApp();

export const addTask = functions.region("asia-northeast1").https
    .onRequest(async (request, response) => {
      try {
        // request.bodyにPOSTされたJSONが入っている
        const token = request.body.token;
        const title = request.body.title;
        // トークンの検証 (省略)

        // firestore(データベース)にデータを追加
        const store = admin.firestore();
        await store.collection("tasks").add({
          title,
        });
        response.send({"result": true});
      } catch (error) {
        response.status(403).send({"error": (error as Error).message});
      }
    });

以上のように、ショートカットアプリを使うことで、iPhoneアプリを開発せずとも音声入力、APIの呼び出しができることがわかりました。サーバサイドのコードさえ書ければ、ハンズフリーで様々なタスクをこなせて便利だと思います。

ふかうら王でMacのCoreMLを使う(成功)

ふかうら王(やねうら王のMCTS+DNNバージョン、つまりDL系将棋AI)でMacGPU, Neural Engineを使うことでMac上で高速動作できるようにしました。

2022年6月13日にやねうら王本家にマージされました。

動かし方

YaneuraOu-v7.6.3-macos.tar.xz を以下のページからダウンロードし、解凍。エンジン本体はFukauraOu-CoreMLディレクトリ内にあります。CPUアーキテクチャにより、M1とAVX2 (Intel系)を使い分けてください。

github.com

評価関数ファイルですが、通常のONNX形式のものが使えず、Apple独自のMLModel形式が必要です。以下のページにサンプルモデルファイルと、将棋所の設定について記載しています。

Release Core ML版サンプルビルド20220613 · select766/FukauraOu-CoreML · GitHub

M1 MacBook Air (2020年)でモデルDlShogiResnet15x224SwishBatch.mlmodel*1が1300NPS程度で動作します。計算にはNeural Engineが使われています。

技術解説

ふかうら王でCore MLを利用するためのプルリクエストが以下になります。

MacのCore MLインターフェース by select766 · Pull Request #249 · yaneurao/YaneuraOu · GitHub

C++製のアプリからObjective-Cを経由してCore MLを呼び出す手段については過去の記事で解説しました。

select766.hatenablog.com

この記事では、ふかうら王の中にCore MLインターフェースを実装する手段を説明します。

source/eval/deep/nn_coreml.h

class NNCoreML : public NN
{
public:
    virtual ~NNCoreML();

    // モデルファイルの読み込み。
    virtual Tools::Result load(const std::string& model_path , int gpu_id , int batch_size);

    // NNによる推論
    virtual void forward(const int batch_size, PType* p1, PType* p2, NN_Input1* x1, NN_Input2* x2, NN_Output_Policy* y1, NN_Output_Value* y2);

    // 使用可能なデバイス数を取得する。
    static int get_device_count();

private:
    int fixed_batch_size; // バッチサイズは、常にload()に与えられたもので動作する。動的にバッチサイズを変更すると実行計画の再生成が起こり、極めて遅くなる。
    void* model; // Objective-Cの型を見せないため
    DType* input_buf;
};

ふかうら王の評価関数の実装は、NNインターフェースとして切り出されています。すでにONNXRuntimeやTensorRTインターフェースがこれに準拠しています。 この仕様に沿った形でCore MLインターフェースを追加すればよいということになります。ここではNNCoreMLクラスとして実装しました。

実装本体はsource/eval/deep/nn_coreml.mmにあります。これはObjective-C++ソースです。以前の記事で書いたとおりのCore ML呼出し手順を記述すればOKです。

注意点として、 NNCoreML::forward(const int batch_size, PType* p1, PType* p2, NN_Input1* x1, NN_Input2* x2, NN_Output_Policy* y1, NN_Output_Value* y2) に渡されるバッチサイズが呼び出しごとに変動することが挙げられます。このバッチサイズをCore MLに渡すと、前回のバッチサイズと異なる場合に実行計画の再生成が起こり、極めて動作が遅くなります。バッチサイズが1→8→1→8と変化すると、毎回再生成されるようです。そのため、NNCoreML::load(const std::string& model_filename , int gpu_id , int batch_size)に渡されたバッチサイズ(forwardで渡される可能性がある最大バッチサイズ)を常に用いるようにします。forwardで小さいバッチサイズが渡されたとしてもパディングして、Core MLでは常に最大バッチサイズで推論させます。バッチサイズ8程度で速度が飽和するため、バッチサイズが8に満たない入力をパディングしたとしても大きく損をすることはありません。

最後にビルドスクリプトの変更ですが、Objective-C++をビルドする機能が必要です。Mac環境のclangではObjective-C++が問題なくビルドできるのでわずかな修正で済みます。

リンカオプションとして、Objective-Cで用いる標準ライブラリを指定するため-frameworkの指定を追加します。

     else ifeq ($(YANEURAOU_EDITION),YANEURAOU_ENGINE_DEEP_COREML)
            CPPFLAGS += -DCOREML
            LDFLAGS += -framework Foundation -framework CoreML
            OBJC_SOURCES += eval/deep/nn_coreml.mm

Objective-C++のソースのビルドは、$(CPPFLAGS)のほかに-fobjc-arcObjective-Cガベージコレクションのためのオプション)を付与します。出来上がる*.oC++をビルドしたものと同等に扱えます。

$(OBJDIR)/%.o: %.mm
    @[ -d $(dir $@) ] || mkdir -p $(dir $@)
    $(COMPILER) $(CPPFLAGS) -fobjc-arc $(INCLUDE) -o $@ -c $<

以上の修正により、Mac上でもハードウェアの性能を最大限生かしてDL系将棋AIを動作させることが可能となりました。NVIDIAGPUが搭載されたWindows環境には及びませんが、手持ちのMac環境を活用してみたいという方はお試しください。

2022-07-02追記: 利用者から連絡がありました。M1 UltraではNeural Engineのコア数がM1よりも多いはずですが、速度は変わらないそうです。Neural Engineの挙動に関してチューニングができるAPIが存在しないため、現時点では工夫する余地がありません。

*1:モデル構造は第2回世界将棋AI電竜戦エキシビションのdlshogiとほぼ同じですが、精度差については不明です

dlshogiモデルをMacのCore MLで動かしてベンチマーク

前回実装した、Objective-Cを用いてCore MLを呼び出すコードを用いて、dlshogiモデルの動作速度をベンチマークしました。

使用するモデルは、「強い将棋ソフトの創りかた」サンプルコードに従い学習した、15ブロック224チャンネル(resnet15x224_swish)のモデルです。比較するのは、使用するcompute units (Neural Engine / GPU / CPU)および、ONNX Runtimeを使用した場合です。ONNX Runtimeは、ONNX RuntimeデフォルトのCPU実装と、処理の一部をCore MLに移譲するCoreML Execution Providerの2種類を動作させました。CoreML Execution Provider内で使用されるcompute unitsについては指定する手段がありません。バッチサイズは1および16を試しました。ハードウェアは、Intel Mac (MacBook Air (Retina, 13-inch, 2019))およびM1 Mac (MacBook Air (M1, 2020) (GPUコアは8個))を用いました。測定時間は10秒で、その間に処理できたサンプル数を計測します。また発熱による測定間の影響がないよう、各測定の間に60秒以上の間隔をあけました。

ハードウェア バッチサイズ 実行条件 サンプル/秒
M1 1 CPU 145
M1 1 GPU 112
M1 1 NE 873
M1 1 ORT 93
M1 1 ORT+CoreML 93
M1 16 CPU 257
M1 16 GPU 581
M1 16 NE 1329
M1 16 ORT 100
M1 16 ORT+CoreML 100
Intel 1 CPU 28
Intel 1 GPU 46
Intel 1 NE 46
Intel 1 ORT 48
Intel 1 ORT+CoreML 46
Intel 16 CPU 45
Intel 16 GPU 80
Intel 16 NE 78
Intel 16 ORT 60
Intel 16 ORT+CoreML 60

ORT: ONNX Runtime。NE: Neural Engine。ハードウェアがIntelの場合、Neural Engineは搭載されていないため、実際にはGPUで計算されていると考えられます。

M1 MacでのNeural Engineの性能が際立ちます。バッチサイズ16で1329サンプル/秒(将棋エンジンで言えば1329NPS)となりました。CPUの5倍以上、GPUの2倍以上の速度となっています。バッチサイズ1の場合、GPUはかなりスループットが下がりましたが、Neural Engineでのスループット低下は抑えられています。Neural Engineはその目的上DNNの推論に特化しているため、バッチサイズ1での実行を重視した設計になっている可能性があります。ONNX Runtimeは、CoreML Execution Providerを使用しても性能が変化していません。実際にはCore MLが使われていないのかもしれません。Intel Macでは、Core MLでCPUを利用した場合よりONNX Runtimeのほうが高速に動作していますが、M1 Macでは逆になっています。Core MLがM1チップ向けに最適化されているものと予想できます。

なお、M1 MacとNeural Engineの組み合わせでさらに検証したところ、バッチサイズ5で1301サンプル/秒を達成し、バッチサイズを256にしても1335サンプル/秒となりバッチサイズに対してほとんど性能が変わりませんでした。また、2分間連続して動作させても発熱による性能低下は見られませんでした。

ふかうら王をMacで動作させる場合、現在実装されているONNX RuntimeでCPUのみを使うコードと比べ、Core MLでNeural Engineを使うコードへと置き換えることで13倍の速度向上が見込めるということになります。今後、Core MLを呼び出す機構をふかうら王に実装してみたいと思います。

dlshogiモデルをMacのCore MLで動かす

Macで高速にdlshogiモデルを動作させる話題の続きです。前回は、ONNX Runtimeを介してCore MLを呼び出してみましたが、ONNX Runtime単体(CPUのみの利用)と比べ高速化しなかったという結果でした。今回は、ONNX Runtimeを使わず直接Core MLを呼び出してみます。

※2022-06-02修正: メモリリークを回避

select766.hatenablog.com

ふかうら王の改造ではなく、dlshogiモデルを動作させるだけの最小限のコードを実装しました。

Core MLを直接呼び出すコードはこちら。以下のコード解説はこちらのリポジトリの内容を引用しています。

github.com

比較のため、ONNX Runtimeを介してCore MLを呼び出すコードも実装してあります。

github.com

Objective-CからCore MLを呼び出す

Core MLのAPIはSwiftかObjective-Cで呼び出す必要があります。型定義などがObjective-Cのヘッダでなされているため、C++から直接呼ぶことは難しいです。

今回のためにObjective-Cに入門して、まずはObjective-C単体でCore MLを呼び出すコードを実装しました。

dlshogiモデルは以前紹介した方法で、Core ML特有のDNNモデル形式であるmlmodelに変換しています。

select766.hatenablog.com

下準備として、mlmodelをコンパイルし、mlmodelcを作成する必要があります。以下のコマンドによって行うことができます。

/Applications/Xcode.app/Contents/Developer/usr/bin/coremlc compile DlShogiResnet15x224SwishBatch.mlmodel .

コンパイル結果は、 DlShogiResnet15x224SwishBatch.mlmodelc として出力されます。これはファイルではなくディレクトリで、中にメタデータや学習済みの重みなど複数のファイルが含まれていました。コンパイルオプションにターゲットとしてmacos, iosなどがあるため、プラットフォーム依存の内容と思われます。コマンドラインツールではなく、Core MLのAPIを用いてのコンパイルも可能なので、ユーザがモデルを差し替えられるような機構にするならそちらを使うほうがポータビリティが上がると思われます。

次に、DNNモデル固有の入出力インターフェースのコードを生成します。

/Applications/Xcode.app/Contents/Developer/usr/bin/coremlc generate DlShogiResnet15x224SwishBatch.mlmodel .

生成結果は、DlShogiResnet15x224SwishBatch.m (Objective-Cのソース)、DlShogiResnet15x224SwishBatch.h (Objective-Cのヘッダ)として出力されます。薄いラッパーに過ぎないので、モデルの差し替えを可能とするならこれも使用しないほうが良いかもしれません。

これらを用いて、モデルを実行するコードを実装します。

https://github.com/select766/mac-coreml-objc-cpp/blob/main/mainobjc.m

#import <CoreML/CoreML.h>
#import "DlShogiResnet15x224SwishBatch.h"

Core MLのヘッダをロードします。

MLModelConfiguration* config = [MLModelConfiguration new];
config.computeUnits = MLComputeUnitsAll;

モデルのロードに関する設定を行います。どのcompute unit (Neural Engine / GPU / CPU)で実行されるかを指定できます。

NSError *error = nil;
DlShogiResnet15x224SwishBatch* model = [[DlShogiResnet15x224SwishBatch alloc] initWithConfiguration:config error:&error];

モデルを読み込みます。カレントディレクトリの DlShogiResnet15x224SwishBatch.mlmodelc が読まれます。

float *input_data, *output_policy_expected, *output_value_expected;
read_test_case(&input_data, &output_policy_expected, &output_value_expected);

テスト用の入力データおよび、PyTorchで生成した正しい出力をfloat配列に読み込みます。

MLMultiArray *model_input = [[MLMultiArray alloc] initWithDataPointer:input_data shape:@[[NSNumber numberWithInt:batch_size], @119, @9, @9] dataType:MLMultiArrayDataTypeFloat32 strides:@[@(119*9*9), @(9*9), @9, @1] deallocator:NULL error:NULL];

入力テンソルを表すMLMultiArrayを生成します。入力データへのポインタ、テンソルの形状を指定します。

DlShogiResnet15x224SwishBatchOutput *model_output = [model predictionFromInput:model_input error:&error];

モデルを実行します。実行は同期的に行われます。

check_result(output_policy_expected, (float*)output_policy.dataPointer, batch_size * policy_size);

結果に対して処理を行います。結果は、Core ML内部で動的に確保されたバッファ内に保存されており、floatポインタとして取り出すことが可能です。

gcc -o mainobjc -fobjc-arc -O3 mainobjc.m DlShogiResnet15x224SwishBatch.m -framework Foundation -framework CoreML

コンパイルgccコマンドでできます。-frameworkという特有の指定があることに注意してください。-fobjc-arcをつけることで、明示的なメモリ開放なくObjective-Cのオブジェクトに対して参照カウント式のGC (ARC: Automatic Reference Counting) が動きます。

さらに、モデルを差し替えることを考慮し、mlmodelを動的にコンパイルするバージョンも作成しました。詳細は mainobjcdynamic.m を参照ください。

C++からCore MLを呼び出す

ふかうら王はC++で実装されており、DNN評価を行うクラスをC++で実装する必要があります。C++で定義されたクラスの中で、Objective-CAPIを呼び出すことができますのでこれを用いてCore MLをラップしました。

ラッパークラスのヘッダ nnwrapper.hpp を作成します。これは、Objective-Cの要素がない純粋なC++です。

// pure C++ code

class NNWrapper {
public:
    NNWrapper(const char* computeUnits);
    bool run(int batch_size, float* input, float* output_policy, float* output_value);
    static const int input_size = 119 * 9 * 9;
    static const int policy_size = 2187;
    static const int value_size = 1;
private:
    void* model;
};

メンバ変数void* modelに、Objective-Cのオブジェクトを格納するようになっています。

ラッパークラスの実装 nnwrapper.mm を作成します。これは、Objective-CC++が混在したコード(Objective-C++と呼ばれる)です。

// Objective-C, C++混在コード
#import <Foundation/Foundation.h>
#import <CoreML/CoreML.h>
#import <stdlib.h>
#import "nnwrapper.hpp"
#import "DlShogiResnet15x224SwishBatch.h"

NNWrapper::NNWrapper(const char* computeUnits) {
    MLModelConfiguration* config = [MLModelConfiguration new];
    // 使用デバイス
    // MLComputeUnitsCPUOnly = 0,
    // MLComputeUnitsCPUAndGPU = 1,
    // MLComputeUnitsAll = 2
    MLComputeUnits cu;
    if (strcmp(computeUnits, "cpuonly") == 0) {
        cu = MLComputeUnitsCPUOnly;
    } else if (strcmp(computeUnits, "cpuandgpu") == 0) {
        cu = MLComputeUnitsCPUAndGPU;
    } else if (strcmp(computeUnits, "all") == 0) {
        cu = MLComputeUnitsAll;
    } else {
        fprintf(stderr, "Unknown computeUnits %s\n", computeUnits);
        exit(1);
    }
    config.computeUnits = cu;
    NSError *error = nil;
    DlShogiResnet15x224SwishBatch* model = [[DlShogiResnet15x224SwishBatch alloc] initWithConfiguration:config error:&error];
    NSLog(@"%@", model);
    NSLog(@"%@", error);

    if (!model) {
        NSLog(@"Failed to load model, %@", error);
        exit(1);
    }
    // 所有権をARCからプログラマに移す
    this->model = (void*)CFBridgingRetain(model);
}

bool NNWrapper::run(int batch_size, float* input, float* output_policy, float* output_value) {
    // 所有権を移さない(プログラマのまま)
    DlShogiResnet15x224SwishBatch* model = (__bridge DlShogiResnet15x224SwishBatch*)(this->model);

    MLMultiArray *model_input = [[MLMultiArray alloc] initWithDataPointer:input shape:@[[NSNumber numberWithInt:batch_size], @119, @9, @9] dataType:MLMultiArrayDataTypeFloat32 strides:@[@(119*9*9), @(9*9), @9, @1] deallocator:NULL error:NULL];

    NSError *error = nil;
    @autoreleasepool { // Core ML内部で確保されたメモリを解放するのに必要
        DlShogiResnet15x224SwishBatchOutput *model_output = [model predictionFromInput:model_input error:&error];
        if (error) {
            NSLog(@"%@", error);
            return false;
        }

        // 出力は動的確保された領域に書き出されるため、これを自前のバッファにコピー
        memcpy(output_policy, model_output.output_policy.dataPointer, batch_size * NNWrapper::policy_size * sizeof(float));
        memcpy(output_value, model_output.output_value.dataPointer, batch_size * NNWrapper::value_size * sizeof(float));
    }

    return true;
}

NNWrapper::~NNWrapper() {
    // 所有権をARCに返す
    DlShogiResnet15x224SwishBatch* model = CFBridgingRelease(this->model);
    // スコープを外れるので解放される
}

C++メンバ関数内で、Objective-CAPIを呼び出すことができます。呼び出しの手順はObjective-Cのみで実装したコードと同じです。ただし、メモリ管理について注意点があります。C++オブジェクトのメンバ変数(this->model)にObjective-Cで生成したモデルオブジェクトへのポインタを格納しています。voidポインタにキャストしてしまうとARCが機能しないため、CFBridgingRetainという関数を用いてオブジェクトの所有権をプログラマに渡します(プログラマの責任で明示的に開放する必要がある)。(__bridge DlShogiResnet15x224SwishBatch*)(this->model);は所有権を移さないキャスト、CFBridgingReleaseはARCに所有権を返すキャストです。これによりデストラクタでモデルが解放されます。本来これだけでよいはずですが、[model predictionFromInput:...]の箇所を@autoreleasepoolで囲う必要があります。これがないとメモリリークすることが観測されました。C++と連携しない、Objective-C単独のプログラムではリークしませんでした。以前Swiftから呼び出したときも@autoreleasepoolがないとメモリリークする事例があったため、Core ML特有の問題と思われます。以上の処理で、推論を数分間にわたり繰り返してもメモリリークは見られませんでした。ただし、モデル自体のロード・解放を1000回繰り返すとプロセスのメモリ使用量が50MB程度となりました。このような極端な使い方はなかなかしないと思われるので、許容することとします。

このように定義したラッパークラスは、C++のアプリのコードから次のように呼び出します。特にObjective-Cの要素はありません。

#include "nnwrapper.hpp"

NNWrapper nnwrapper(backend);
nnwrapper.run(batch_size, input.data(), output_policy.data(), output_value.data()); // vector<float>.data()

ビルドに用いるMakefileの抜粋を示します。Objective-Cソースのコンパイルには-fobjc-arcをつけています。

maincpp: maincpp.o nnwrapper.o DlShogiResnet15x224SwishBatch.o DlShogiResnet15x224SwishBatch.mlmodelc
    g++ -o maincpp maincpp.o nnwrapper.o DlShogiResnet15x224SwishBatch.o -framework Foundation -framework CoreML

maincpp.o: maincpp.cpp
    g++ -c --std=c++11 -O3 $<

nnwrapper.o: nnwrapper.mm
    g++ -c -fobjc-arc -O3 $<

DlShogiResnet15x224SwishBatch.o: DlShogiResnet15x224SwishBatch.m
    g++ -c -fobjc-arc -O3 $<

それぞれのソースを別個にコンパイルした後、リンクしています。非常に単純なコードであればgccコマンド1回でビルドも可能でしたが、C++固有のオプションをつけた場合はエラーとなりました。

gcc -o maincpp --std=c++11 maincpp.cpp nnwrapper.mm DlShogiResnet15x224SwishBatch.m -framework Foundation -framework CoreML
# error: invalid argument '--std=c++11' not allowed with 'Objective-C'

このやり方で、Core MLを動作させることができました。次回、ベンチマーク結果を示します。

ふかうら王でMacのCoreMLを使う(失敗)

ふかうら王(やねうら王のMCTS+DNNバージョン)でMacGPU, Neural Engineを使うことでMac上で高速動作できるか検証しました。しかし、今回のやり方ではうまくいきませんでした。

現象: onnxruntime上でCoreMLを利用する設定をしたが、onnxruntimeデフォルトのCPUプロバイダ利用時と速度が変わらない

失敗原因の仮説

  • (今回実験した)Intel MacではCoreMLがCPUしか使わない
  • dlshogiのモデルで使われているオペレータが、onnxruntimeがCoreMLを使う条件にヒットしない

実験手順メモ

今回は、ふかうら王で既にサポートされているonnxruntimeの内部で、CoreML Execution Providerを使うように改造します。

手順は以下の記事を参考にしましたが、やねうら王のバージョンアップで若干違いが出ました。

qiita.com

まずはCPUのみの動作を試し、その後CoreMLを使うように改造しました。

環境について

Intel Macなので、Neural Engineはついていません。

onnxruntimeのインストール

注意:これで入るものはCoreMLに対応していないようなので、後で削除します。

brew install onnxruntime

バージョン1.11.1が入りました。

やねうら王のclone

やねうら王のmaster (23a7f2a88f98bcbfcaf8e625fe1448ea8d612e2b)をclone

ソースの修正

先述の記事に従って、AVX2を使うように修正しました。他の修正点は最新バージョンのやねうら王では不要でした。

注:これが正しいのかよく理解していません。また、CoreMLとは関係ないのでこの実験においては修正しなくても良いと思います。

diff --git a/source/Makefile b/source/Makefile
index 94795789..734b218c 100644
--- a/source/Makefile
+++ b/source/Makefile
@@ -548,7 +548,7 @@ else ifeq ($(TARGET_CPU),AVX512)
        CPPFLAGS += -DUSE_AVX512 -DUSE_BMI2 -march=skylake-avx512

 else ifeq ($(TARGET_CPU),AVX2)
-       CPPFLAGS += -DUSE_AVX2 -DUSE_BMI2 -mbmi -mbmi2 -mavx2 -march=corei7-avx
+       CPPFLAGS += -DUSE_AVX2 -DUSE_BMI2 -mbmi -mbmi2 -mavx2 -march=core-avx2

 else ifeq ($(TARGET_CPU),SSE42)
        CPPFLAGS += -DUSE_SSE42 -msse4.2 -march=corei7

ビルド

make -C source COMPILER=g++ EXTRA_CPPFLAGS="-I/usr/local/include/onnxruntime/core/session -I/usr/local/include/onnxruntime/core/providers/cpu -fexceptions" EXTRA_LDFLAGS="-L/usr/local/lib -lonnxruntime" YANEURAOU_EDITION=YANEURAOU_ENGINE_DEEP_ORT_CPU

実行ファイルが source/YaneuraOu-by-gcc にできたので、将棋所にエンジン登録します。

モデルの準備

https://tadaoyamaoka.hatenablog.com/entry/2021/08/17/000710 からダウンロードして解凍 source/model-dr2_exhi/model-dr2_exhi.onnx にモデルファイルが存在するようにする。

将棋所での設定

  • EvalDir: model-dr2_exhi
  • DNN_Model1: model-dr2_exhi.onnx

実行

この状態で将棋所でLesserkaiと対局することができました。CPUのみの利用で、40NPS程度でした。

改造する

やねうら王のソースは以下のように修正しました。CPUプロバイダはコメントアウトして、必ずCoreMLが使われるようにしています。

Core ML Execution Providerの説明

diff --git a/source/eval/deep/nn_onnx_runtime.cpp b/source/eval/deep/nn_onnx_runtime.cpp
index 3f11de70..e5e89178 100644
--- a/source/eval/deep/nn_onnx_runtime.cpp
+++ b/source/eval/deep/nn_onnx_runtime.cpp
@@ -14,6 +14,7 @@
 #include <tensorrt_provider_factory.h>
 #else
 #include <cpu_provider_factory.h>
+#include <coreml_provider_factory.h>
 #endif
 #include "../../usi.h"

@@ -28,6 +29,9 @@ namespace Eval::dlshogi
                Ort::SessionOptions session_options;
                session_options.DisableMemPattern();
                session_options.SetExecutionMode(ORT_SEQUENTIAL);
+               Ort::Env env = Ort::Env{ORT_LOGGING_LEVEL_ERROR, "Default"};
+               uint32_t coreml_flags = 0;
+               Ort::ThrowOnError(OrtSessionOptionsAppendExecutionProvider_CoreML(session_options, coreml_flags));
 #if defined(ORT_DML)
                Ort::ThrowOnError(OrtSessionOptionsAppendExecutionProvider_DML(session_options, gpu_id));
 #elif defined(ORT_TRT)
@@ -64,7 +68,7 @@ namespace Eval::dlshogi
                // Ort::ThrowOnError(OrtSessionOptionsAppendExecutionProvider_Tensorrt(session_options, gpu_id));
                Ort::ThrowOnError(OrtSessionOptionsAppendExecutionProvider_CUDA(session_options, gpu_id));
 #else
-           Ort::ThrowOnError(OrtSessionOptionsAppendExecutionProvider_CPU(session_options, true));
+           //Ort::ThrowOnError(OrtSessionOptionsAppendExecutionProvider_CPU(session_options, true));
 #endif
 #if defined(_WIN32)
                // Windows環境ではwstringでファイル名を渡す必要があるようだが?

CoreMLに対応したonnxruntimeの入手

brewで入るonnxruntimeは、ヘッダにcoreml関係が入っておらず非対応のようでした。dylibファイルのサイズも小さいです。

干渉しないように一旦アンインストールしました。

brew uninstall onnxruntime

GithubのReleasesから、ビルド済みバイナリ(onnxruntime-osx-x86_64-1.11.1.tgz)とソース(Source code (tar.gz))両方を入手します。ビルド済みバイナリの方には、coreml関係のヘッダがなかったためです。

https://github.com/microsoft/onnxruntime/releases/tag/v1.11.1

ビルド

make -C source COMPILER=g++ EXTRA_CPPFLAGS="-I/path/to/onnxruntime-1.11.1/include/onnxruntime/core/session -I/path/to/onnxruntime-1.11.1/include/onnxruntime/core/providers/cpu -I/path/to/onnxruntime-1.11.1/include/onnxruntime/core/providers/coreml -fexceptions" EXTRA_LDFLAGS="-L/path/to/onnxruntime-osx-x86_64-1.11.1/lib -lonnxruntime" YANEURAOU_EDITION=YANEURAOU_ENGINE_DEEP_ORT_CPU

強引ですが、インクルードファイルはソースコードを解凍したもの、ライブラリファイルはビルド済みバイナリを解凍したものを指します。

ライブラリを検索パスにコピー

sudo cp -a /path/to/onnxruntime-osx-x86_64-1.11.1/lib/libonnxruntime.1.11.1.dylib /usr/local/lib

セキュリティ許可

ビルドしたバイナリを初回実行するとセキュリティエラーが出るので、「セキュリティとプライバシー」で許可します。

実行結果

エラーは出ずに実行できましたが、速度は40NPS程度のままでした。アクティビティモニタGPU使用率を確認しましたが、対局中にGPU使用率が上がるとは言えませんでした。

onnxruntimeのソースを軽く読んだ感じだと、モデルをCoreMLに丸投げするのではなく、onnxruntime側で解釈した上でCoreMLに処理を移譲出来る部分を個別に移譲するような仕組みのようです。以下のパターンが考えられますが、検証方法がまだわかりません。

  • dlshogiモデルに含まれるオペレータがCoreMLへ変換できない
  • オペレータをCoreMLに移譲しているが、CoreML側の判断でCPU上で実行されており、速度面ではonnxruntimeのオペレータ実装と違いがない
    • Intel Mac上のCoreMLがGPUを使う仕様になっているかどうか、確認できていない

今後の課題

原因を切り分ける必要があります。onnxruntimeを使わずにCoreMLを直接呼び出すプログラムであれば、dlshogiモデルがGPU上で動作するかを検証したいと思います。

C++製将棋AI(shogi686micro)をiPadで動かす

これまでiPad上で動く将棋AIをSwift言語で作ってきて、一応動くというところまで行きました。

select766.hatenablog.com

DNNの評価がボトルネックだったのでSwiftで書いても問題なかったのですが、CPUで大量の計算が必要となる詰み探索などを付加して発展させていくことを考えると、既存の資産が使えることやインラインアセンブラが書けることから、エンジンのコアはC++で実装できることが望ましいです。 そこでコンピュータ将棋開発の当面の目標を、iPad上にやねうら王・ふかうら王(やねうら王のMCTS+DNNバージョン)を移植することにしました。移植したうえで、NNUE/KPPT型とDNNのどっちが強いかや、iPadに適したニューラルネットワークの構造などの調査をしようと思います。

Xcode上でC++を扱うのが初めてなので、やねうら王のように複数のソースファイルや評価関数ファイルの読み込みが絡む将棋AIではなく、1ファイルで完結するシンプルな将棋AIであるshogi686microを移植してみました。以下にそのポイントを記します。 iPadアプリとして成立させるにはC++だけでなく、SwiftかObjective-CGUIを作成する必要があります。そのため、XcodeでSwift言語を使うプロジェクトを作成したうえで、将棋AI部分のC++コードを追加してビルドします。

作ったものがこちら。

github.com

実装

他のエンジンとの対局は、以前開発したようにTCPで将棋所Macにつないで行います。

select766.hatenablog.com

オリジナルではUSIメッセージはstdioでやり取りする実装ですが、この内容をソケットでやり取りするように改造しました。boostを使うとstreamとしてTCPを使えるので簡単なのですが、iOS向けビルドの仕方がよくわかりませんでした。

  • ContentView.swift: GUIの実装。
  • Shogi686MicroiOSApp.swift: プロジェクトのエントリポイント。テンプレートから変更なし。
  • Shogi686MicroiOS-Bridging-Header.h: Swiftから参照したいC言語の関数の宣言をincludeする。中身は#include "micro.hpp"だけ。
  • micro.hpp: 関数宣言。C言語(not C++)としてコンパイル可能である必要がある。
  • micro.cpp: エンジン本体。C++で実装するが、Swiftから呼ばれる関数にはextern "C"をつける。将棋所とのTCP通信をsocketで素直に実装。

micro.hpp:

#ifndef micro_hpp
#define micro_hpp

#include <stdio.h>
#ifdef __cplusplus
extern "C" {
#endif

// コールバック関数の型定義
typedef void message_cb(const char* msg);
// Swiftから呼び出される関数
int micro_main(const char* server_ip, int server_port, message_cb cb);

#ifdef __cplusplus
}
#endif
#endif /* micro_hpp */

micro.cppの抜粋:

// Swift側に、文字列をコールバックするための関数ポインタ
message_cb *cb;

// USIのメッセージをTCPソケット(fd_socket)に送信
void socket_send_string(ostringstream &oss) {
    string s = oss.str();
    cerr << "SEND: " << s;
    const char* cstr = s.c_str();
    size_t len = strlen(cstr);
    cb(cstr); // 送信した文字列をSwift側にコールバックする(コールバックの例)
    if (send(fd_socket, cstr, len, 0) < len) {
        cerr << "partial send error" << endl;
    }
}

// Swiftで、実行ボタンをタップしたときに呼び出される関数。
extern "C" int micro_main(const char* server_ip, int server_port, message_cb _cb) {
    cb = _cb;
    if (!socket_connect(server_ip, server_port)) {
        return 1;
    }
    // micro_main自体はメインスレッド(GUIスレッド)で呼ばれるので、別のスレッドを立てて通信・思考する
    thread thread(usiLoop);
    thread.detach();
    return 0;
}

ContentView.swiftの抜粋:

var _cb: (String) -> Void = {_ in}
func registerCallback(cb: @escaping (String) -> Void) {
    _cb = cb
}
// C側から呼ばれる関数はグローバル関数として定義する必要がある(static methodもダメ)
func globalCallback(messagePtr: UnsafePointer<CChar>?) -> Void {
    // C側からchar*として渡された文字列はこのようにしてSwiftのStringに変換する
    let messageString = (CFStringCreateWithCString(kCFAllocatorDefault, messagePtr, kCFStringEncodingASCII) ?? "" as CFString) as String
    // ここでコンテキスト依存のクロージャを呼び出すことができる
    _cb(messageString)
}

struct ContentView: View {
    @State var usiServerIpAddress = "127.0.0.1"
    @State var usiServerPort = "8090"
    @State var lastMessage = ""

    var body: some View {
        TextField("USI IP", text: $usiServerIpAddress).keyboardType(.asciiCapable).disableAutocorrection(true).frame(width: 200.0, height: 20.0)
        TextField("USI Port", text: $usiServerPort).keyboardType(.asciiCapable).disableAutocorrection(true).frame(width: 200.0, height: 20.0)
        Button(action: {
            // C側でconst char *として受け取る文字列はこのように作る
            let server_ip_nss = usiServerIpAddress as NSString
            let server_ip_ptr = UnsafeMutablePointer<CChar>(mutating: server_ip_nss.utf8String)
            // コンテキスト(self)を含んだクロージャはC側に直接渡せない。クロージャをグローバル変数(_cb)に保存し、コールバックされるグローバル関数(globalCallback)から間接的に呼び出してもらう
            registerCallback(cb: {
                message in DispatchQueue.main.async {
                    self.lastMessage = message
                }
            })
            micro_main(server_ip_ptr, Int32(usiServerPort)!, globalCallback);
        }) {
            Text("RUN")
        }
        Text(lastMessage).padding()
    }
}

SwiftとCで文字列や関数の表現が違うので変換処理が必要ですが、整数はそのまま渡せるので意外と簡単でした。プロジェクト設定として、C++を使うための特別な設定は不要で、C++ファイルを追加すれば自動的にコンパイル対象に含めてくれました。

関数ポインタはインスタンスメソッドを渡せないので若干面倒です。昔ながらのWindows APIにはこの手の課題を解決するヒントがあります。EnumWindowsでは、コールバック関数のほかに整数1個を渡すことができるようになっていて、コールバック関数の引数としてその整数が与えられるようになっています。この値を呼び出しごとに変えることで、どういう文脈でコールバック関数が呼ばれたか判断できます。整数値と文脈の対応付けは呼び出し側で管理する必要があります。今回の課題では文脈を区別する必要がないので雑に済ませました。

実行結果

shogi686microをiPadで動かしたときの画面

うまく動きました。iPad第9世代で、序盤で200万NPS程度という表示でした。1手10秒で、lesserkaiより若干強いといった棋力でした。

今後は、やねうら王の移植を試みたいと思います。