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

iPadでやねうら王とふかうら王を同時に動作させる 標準入出力衝突回避編

やねうら王・ふかうら王を1つのプロセスで動作させるための課題として、前回はシンボル名の衝突に対処しました。今回は、標準入出力の衝突に対処します。

以前、やねうら王単独のビルドにおいてC++の標準入出力(cin/cout)をTCPソケットにリダイレクトし、端末の外と通信できるようにしました。

select766.hatenablog.com

今回もこの仕組みを活用して合議エンジンがあるMacと通信しますが、このままですとやねうら王とふかうら王の標準入出力が混ざってしまい通信ができません。解決のアイデアは2通りあります。

  1. 行の先頭に、エンジンを識別する記号を付与し、受信側でその記号を用いて処理を振り分ける
  2. やねうら王とふかうら王で異なるTCPソケットを用いる

最初にアイデア1を検討しましたが、coutが使われている箇所が1000件以上あり、1行を出力するために複数回coutを使用している場面(例: cout << "bestmove " << pv[0]; cout << "ponder " << pv[1];)があり、行の先頭に記号を付与するという書き換えが難しいことがわかりました。そのため、アイデア2を実現することとしました。どちらのエンジンが入出力を行おうとしているのか判定する手段は、スレッドのIDを用います。エンジンに紐づいたスレッドを起動した際に、そのスレッドのIDを記録します。

std::mutex mutex_thread_engine_map;
std::map<std::thread::id, int> thread_engine_map; // スレッドID -> エンジン番号(DEEP=0, NNUE=1)
void register_iostream_thread(int engine_id)
{
    std::lock_guard<std::mutex> lock(mutex_thread_engine_map);
    thread_engine_map[std::this_thread::get_id()] = engine_id;
}

inline void register_iostream_thread()
{
#if defined(YANEURAOU_ENGINE_DEEP)
register_iostream_thread(0);
#elif defined(YANEURAOU_ENGINE_NNUE)
register_iostream_thread(1);
#endif
}

スレッドが起動される箇所は2か所です。GUIから明示的に立ち上げるスレッドと、並列探索のためにエンジン内部で立ち上げるスレッドがあります。それぞれの先頭でregister_iostream_threadを呼び出します。

// GUIから立ち上げるスレッド
namespace YANEURAOU_GOUGI_NAMESPACE {
    void yaneuraou_ios_thread_main() {
        register_iostream_thread();
    }
}

// 並列探索用スレッド
void Thread::idle_loop() {
    register_iostream_thread();
}

スレッドIDとエンジンの紐づけがthread_engine_mapに記録されるので、それを用いて標準入出力を呼び出したスレッドを特定し、エンジン別に生成したTCPソケットを用いて通信します。

static const int n_engines = 2;
static int socket_fds[n_engines] = {0};
static std::map<std::thread::id, int> thread_engine_map; // スレッドID -> エンジン番号(DEEP=0, NNUE=1)

static int socket_connect(const char* server_ip, int server_port) {
    int socket_fd = socket(AF_INET, SOCK_STREAM, 0);
    if (socket_fd == -1) {
        std::cerr << "Failed to create socket" << std::endl;
        return false;
    }
    struct sockaddr_in sin;
    sin.sin_family = AF_INET;
    sin.sin_addr.s_addr = inet_addr(server_ip);
    sin.sin_port = htons(server_port);

    int ret = connect(socket_fd, (const struct sockaddr*)&sin, sizeof(sin));
    if (ret == -1) {
        std::cerr << "Failed to connect tcp server" << std::endl;
        return -1;
    }

    return socket_fd;
}

int get_socket_for_thread()
{
    auto id = std::this_thread::get_id();
    std::lock_guard<std::mutex> lock(mutex_thread_engine_map);
    auto it = thread_engine_map.find(id);
    if (it != thread_engine_map.end())
    {
        return socket_fds[it->second];
    }
    else
    {
        std::cerr << "get_socket_for_thread: thread " << id << " is not registered" << std::endl;
        return socket_fds[0];
    }
}

class myoutstreambuf : public std::streambuf {
public:
    myoutstreambuf() {
    }

protected:
    int overflow(int nCh = EOF) {
        if (nCh >= 0) {
            char c = (char)nCh;
            if (send(get_socket_for_thread(), &c, 1, 0) < 1) {
                return EOF;
            }
        }
        return nCh;
    }
};

class myinstreambuf : public std::streambuf {
public:
    myinstreambuf() {
    }

protected:
    int uflow() {
        char buf;
        if (recv(get_socket_for_thread(), &buf, 1, 0)) {
            return buf;
        } else {
            return EOF;
        }
    }
};

extern "C" int yaneuraou_ios_main(const char* deep_server_ip, int deep_server_port, const char* deep_modelc_url, int deep_coreml_compute_units, const char* nnue_server_ip, int nnue_server_port, const char* nnue_file_path) {
    // stdioを独自に切り替え
    myoutstreambuf *outs = new myoutstreambuf();
    myinstreambuf *ins = new myinstreambuf();
    std::cout.rdbuf(outs);
    std::cin.rdbuf(ins);
    // 各エンジン用のTCPソケットを生成
    socket_fds[0] = socket_connect(deep_server_ip, deep_server_port);
    socket_fds[1] = socket_connect(nnue_server_ip, nnue_server_port);
    // エンジンのスレッドを立てる処理
}

以上の仕組みにより、2つのエンジンの標準入出力を異なるTCPソケットで通信することが可能となり、合議エンジン側で各エンジンを個別に制御できるようになりました。

合議について

合議のアルゴリズムはごく単純なものを実装しました。アピール文書と同様の内容を掲載します。

github.com

使用ハードウェアであるiPad(第9世代)では、NNUEのほうがDL(深層学習)より強い(勝率80%程度)ため、NNUEの指し手を主体として、DLは補助として使用することとした。 評価値はエンジンごとにスケールが異なるため、合議の前に、両者の評価値を勝率に変換する。変換関数は、自己対戦により評価値と勝敗のペアを収集し、シグモイド関数のフィッティングにより求めた。 NNUEではMultiPVにより候補手を2手出力し、DLでは候補手を5手出力する。NNUEでは複数の候補手を出すことは若干の棋力低下につながる一方、DLでは影響がない。NNUEの候補手がDL側にも存在した場合、それらの勝率を係数0.75, 0.25で重みづけ平均したものをその候補手の勝率とみなす。この処理を行ったうえでNNUEの候補手のうち最も勝率が高いものを選択する。すなわち、NNUEの候補手間の勝率差がわずかな場合、DLの出力により順序が逆転する場合がある。 時間管理は特に行わず、両者の指し手がそろった時点で合議処理を行う。Ponderは未実装。 ただし、64手目以降は、詰み付近の処理に強いと考えられるNNUEのみで、MultiPVを用いずに指し手を決定する。

別解(未検証)

iPadでは画面を左右に分割して2つのアプリを同時に動作させるSplit Viewがあるので、もしかしたらやねうら王アプリとふかうら王アプリを別々に作成して同時に起動する、ということができるかもしれません。iPhoneにはこの機能がないため無理と思われます。開発を完了してから気づいたアイデアで、未検証です。

まとめ

iPadの性能をフルに活用した将棋AIを開発するため、CPU主体のNNUE型エンジンとNeural Engine主体のDL型エンジンを単一のプロセス上で動作させるテクニックを開発しました。C++のシンボル名の衝突と、標準入出力の衝突を回避することにより実現できることがわかりました。

以上の内容を搭載した将棋エンジンをソフト名「ねね将棋」として第33回世界コンピュータ将棋選手権に出場します。