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

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より若干強いといった棋力でした。

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