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

【コンピュータ将棋】Apple Watchでやねうら王を動かす

Appleが発売しているスマートウォッチApple Watch Series 9にて、将棋AIやねうら王・ふかうら王を動作させることに成功したので報告します。

基本的には、iPhone向けのビルド方法を少し変えれば実現できました。

技術要素

以下の3要素を実現することにより、Apple Watch上で動作するやねうら王をfloodgate上で他の将棋AIと対局させることができます。

  • やねうら王をApple WatchのCPU向けにビルドし、SPM形式で出力
  • watchOSアプリを作成し、SPMを依存関係に追加し、Swift言語からやねうら王を呼び出す
  • Macで、通信仲介用サーバを実行

SPMのビルド

C++言語で実装されたやねうら王を、Apple WatchのCPU(ARMアーキテクチャ)向けにビルドし、Swift Package Manager (SPM)形式で出力します。この形式のライブラリは、Swift言語で実装されたアプリの依存関係としてロードすることができ、C言語の関数をSwift側から呼び出すことができます。

ソースコードはこのブランチにあります。

https://github.com/select766/YaneuraOuiOSSPM/tree/watchos

iPhone向けのSPMビルドに関する記事はこちら。

select766.hatenablog.com

ここからの差分について説明します。やねうら王のmain関数を除去し、Swift言語側から呼び出されて動作するための追加のコードは以下の通りです。

#include "../../YaneuraOu/source/search.h"
#include "../../YaneuraOu/source/thread.h"
#include "../../YaneuraOu/source/tt.h"
#include "../../YaneuraOu/source/usi.h"
#include "../../YaneuraOu/source/misc.h"
#include "../include/yaneuraou.h"

// cin/coutをsocketにリダイレクト
#include <iostream>
#include <unistd.h>
#include <sys/types.h>

std::string nnue_file_path;

#ifdef __cplusplus
extern "C" {
#endif /* __cplusplus */

typedef int (*yaneuraou_usi_read_cb)();
typedef void (*yaneuraou_usi_write_cb)(int ch);
int yaneuraou_ios_main(yaneuraou_usi_read_cb usi_read, yaneuraou_usi_write_cb usi_write, const char* nnue_file_path);

#ifdef __cplusplus
}
#endif /* __cplusplus */

class myoutstreambuf : public std::streambuf {
private:
    yaneuraou_usi_write_cb usi_write;

public:
    myoutstreambuf(yaneuraou_usi_write_cb _usi_write): usi_write(_usi_write) {
    }

protected:
    int overflow(int nCh = EOF) {
        usi_write(nCh);
        return nCh;
    }
};

class myinstreambuf : public std::streambuf {
private:
    yaneuraou_usi_read_cb usi_read;

public:
    myinstreambuf(yaneuraou_usi_read_cb _usi_read): usi_read(_usi_read) {
    }

protected:
    int uflow() {
        return usi_read();
    }

};

static void yaneuraou_ios_thread_main(yaneuraou_usi_read_cb usi_read, yaneuraou_usi_write_cb usi_write) {
    myoutstreambuf outs(usi_write);
    myinstreambuf ins(usi_read);
    auto default_out = std::cout.rdbuf(&outs);
    auto default_in = std::cin.rdbuf(&ins);

    // --- 全体的な初期化
    int argc = 1;
    char argv0[] = "yaneuraou";
    char *argv[] = {argv0};
    CommandLine::init(argc,argv);
    USI::init(Options);
    Bitboards::init();
    Position::init();
    Search::init();

    // エンジンオプションの"Threads"があるとは限らないので…。
    size_t thread_num = Options.count("Threads") ? (size_t)Options["Threads"] : 1;
    Threads.set(thread_num);

    //Search::clear();
    Eval::init();

#if !defined(__EMSCRIPTEN__)
    // USIコマンドの応答部

    USI::loop(argc, argv);

    // 生成して、待機させていたスレッドの停止

    Threads.set(0);
#else
    // yaneuraOu.wasm
    // ここでループしてしまうと、ブラウザのメインスレッドがブロックされてしまうため、コメントアウト
#endif

    std::cout.rdbuf(default_out);
    std::cin.rdbuf(default_in);
    
    // maybe send disconnect message
}

extern "C" int yaneuraou_ios_main(yaneuraou_usi_read_cb usi_read, yaneuraou_usi_write_cb usi_write, const char* nnue_file_path) {
    ::nnue_file_path = nnue_file_path;
    std::thread thread(yaneuraou_ios_thread_main, usi_read, usi_write);
    thread.detach();
    return 0;
}

Swift言語から、yaneuraou_ios_main関数を呼ぶことにより、やねうら王が動作するスレッド(本来のmain関数)を起動する仕組みになっています。引数として、本来標準入出力でUSIコマンドをやり取りするところを、標準入出力を行うとSwift言語側から関数ポインタとして与えられるusi_read, usi_write関数が呼び出されるようにし、Swift言語にコールバックするようになっています。また、watchOSアプリのアセットとしてNNUE評価関数(水匠5)ファイルを組み込むため、動的に決まるパスを受け取ります。やねうら王ではUSIオプションで評価関数ファイル名を受け取るようになっていますが、ここで指定されたパスから常に読み込むよう、やねうら王のソースを一部書き換えます。ソースの書き換えはスクリプトにより自動化しています。 modify-yaneuraou.py ついでに、ハッシュサイズやスレッド数のデフォルトなどを実行環境に合わせて書き換えます。

ビルドには、 ios-cmake というツールを用います。 cmakeのオプション設定により、Apple Watchアーキテクチャに合わせたバイナリがビルドできます。cmakeを呼び出すスクリプト ポイントとして、Macで動作するシミュレータ用のビルドが別途必要で、Apple Silicon用のビルドとIntel Mac用のビルドの両方がないと、対応するMacでのシミュレータでの実行ができません。両方のビルドを行い、バイナリをlipoコマンドで結合します。

watchOSアプリの作成

Swift言語を用いたwatchOSアプリを作成します。ソースコードはこちら。

GitHub - select766/YaneuraOuWatch: やねうら王(将棋AI) on Apple Watch

プロジェクトのPackage Dependencyに、先ほど作成したSPM(のgitリポジトリとブランチ名)を追加することで、 yaneuraou_ios_main 関数がSwift言語から呼べるようになります。

iOSとの重要な違いとして、TCPソケットが使えません。C言語のsocket、SwiftのNWConnectionのいずれも動作しません。また、WebSocketについても限定的な条件下でしか使えません。そのため、外部とのコマンドの送受信はHTTPで行う必要があります。今回は単純に、やねうら王が入出力するUSIコマンド文字列をHTTP POSTで送受信する仕組みを作りました。

やねうら王を呼び出すためのコードは以下の通りです。これは、iOSと変わりません。やねうら王が文字を出力すると、yaneSendBuffer 変数に格納され、改行文字が来ると yaneRecvCallback が呼ばれます。やねうら王に文字列を読ませたいときは、 sendToYaneuraou 関数に与えると、やねうら王が文字を受け取ろうとした際に1文字ずつ送ります。

import Foundation
import YaneuraOuiOSSPM

// やねうら王とのプロセス内通信関係のバッファ(グローバル関数にするしかない)
var yaneRecvBuffer: Data = Data()
let recvSemaphore = DispatchSemaphore(value: 1)
var yaneSendBuffer: Data = Data()
let sendSemaphore = DispatchSemaphore(value: 1)
var yaneRecvCallback: (String) -> Void = {_ in}

func usiWrite(char: Int32) -> Void {
    // 思考スレッドから呼ばれる
    // 1文字ずつくる。
    // 改行が含まれている。複数行の場合もある。
    // USIクライアント->USIサーバへの送信
    if char < 0 {
        print("usiWrite(EOF)")
        return
    }
    
    sendSemaphore.wait()
    
    if char == 0x0a {
        // end of line
        let completeBuffer = yaneSendBuffer
        yaneSendBuffer = Data()
        // 改行文字は含まない
        yaneRecvCallback(String(data: completeBuffer, encoding: .utf8)!)
    } else {
        yaneSendBuffer.append(contentsOf: [UInt8(clamping: char)])
    }
    
    sendSemaphore.signal()
}

func usiRead() -> Int32 {
    // 思考スレッドから呼ばれる
    // USIサーバ->USIクライアントへの受信
    var item: Int32 = 0
    while true {
        recvSemaphore.wait()
        if yaneRecvBuffer.count > 0 {
            item = Int32(yaneRecvBuffer[0])
            // recvBuffer = recvBuffer.dropFirst()
            // を使うと、次回のrecvBuffer[0]のアクセス時になぜかクラッシュする
            yaneRecvBuffer = Data(yaneRecvBuffer[1...])
            recvSemaphore.signal()
            break
        } else {
            recvSemaphore.signal()
            Thread.sleep(forTimeInterval: 0.1)
        }
    }
    
    return item
}


func stringToUnsafeMutableBufferPointer(_ s: String) -> UnsafeMutableBufferPointer<Int8> {
    let count = s.utf8CString.count
    let result: UnsafeMutableBufferPointer<Int8> = UnsafeMutableBufferPointer<Int8>.allocate(capacity: count)
    _ = result.initialize(from: s.utf8CString)
    return result
}

func startYaneuraou(recvCallback: @escaping (String) -> Void) {
    // やねうら王とのプロセス内通信準備
    // recvCallback: やねうら王からメッセージを受信したときに呼ばれる(改行を含まない1行) 例: "bestmove 7g7f"
    yaneRecvCallback = recvCallback

    // assetのnn.binを評価関数ファイルとして渡す
    guard let nnue_eval_path = Bundle.main.path(forResource: "nn", ofType: "bin") else {
        fatalError()
    }
    let nnue_eval_path_p = stringToUnsafeMutableBufferPointer(nnue_eval_path)

    YaneuraOuiOSSPM.yaneuraou_ios_main(usiRead, usiWrite, nnue_eval_path_p.baseAddress!)
}

func sendToYaneuraou(messageWithoutNewLine: String) -> Void {
    // やねうら王にコマンドを送る 例: "usinewgame"
    let d = (messageWithoutNewLine + "\n").data(using: .utf8)!
    recvSemaphore.wait()
    yaneRecvBuffer.append(d)
    recvSemaphore.signal()
}

UIおよび通信部分は以下の通りです。

import SwiftUI

// FIXME: 将棋所が動いているMacのIPアドレスを指定
let baseURL = "http://192.168.3.12:4090"

struct ContentView: View {
    @State var running = false
    @State var lastMessage = "やねうら王 on Apple Watch"
    @State var writeQueue = [String]()
    @State var writeBusy = false

    var body: some View {
        VStack(alignment: .leading) {
            BatteryView()
            Text(lastMessage).frame(maxWidth: .infinity, alignment: .leading)
            if !running {
                Button(action: start) {
                    Text("Connect")
                }.padding()
            }
            Spacer()
        }.frame(maxWidth: .infinity, maxHeight: .infinity)
    }

    func start() {
        if running {
            return
        }
        running = true
        startYaneuraou(recvCallback: { messageFromYane in
            DispatchQueue.main.async {
                doWrite(message: messageFromYane)
            }
        })
        doRead()
        writeNext()
    }
    
    func doWrite(message: String) {
        // ここで直接HTTP POSTすると順序が乱れる場合があるためキューに入れて順番に送信
        writeQueue.append(message)
    }
    
    func writeNext() {
        if writeQueue.count == 0 {
            DispatchQueue.main.asyncAfter(deadline: .now() + 0.1, execute: writeNext)
            return
        }
        
        let message = writeQueue.joined(separator: "\n")
        writeQueue.removeAll()
        lastMessage = "> \(message)"
        let url = URL(string: "\(baseURL)/write")!
        var request = URLRequest(url: url)
        request.httpMethod = "POST"
        request.httpBody = message.data(using: .utf8)
        let task = URLSession.shared.dataTask(with: request) { (data, response, error) in
            DispatchQueue.main.async {
                writeNext()
            }
        }
        task.resume()
    }
    
    func doRead() {
        let url = URL(string: "\(baseURL)/read")!
        var request = URLRequest(url: url)
        request.httpMethod = "POST"
        let task = URLSession.shared.dataTask(with: request) { (data, response, error) in
            guard let data = data else { lastMessage = "Error: " + String(describing: error); return; }
            let messages = String(data: data, encoding: .utf8)!
            
            var nextTime = DispatchTime.now()
            if messages.count > 0 {
                lastMessage = "< \(messages)"
                for message in messages.split(separator: "\n") {
                    sendToYaneuraou(messageWithoutNewLine: String(message))
                }

                nextTime = nextTime + 0.01
            } else {
                nextTime = nextTime + 0.1
            }
            
            DispatchQueue.main.asyncAfter(deadline: nextTime, execute: doRead)
        }
        task.resume()
    }
}

やねうら王から外部への送信は、doWrite関数でやねうら王が出力した文字列をキューに入れ、一定時間間隔で呼び出されるwriteNext関数でキューにある文字列をHTTPサーバに送信します。受信は、一定時間間隔で呼び出されるdoRead関数により、HTTPサーバから取得します。あくまでApple Watchでの動作検証が目的のため、局面の表示機能は搭載せず、Mac上で動作する将棋所と通信するだけの機構としました。

通信仲介用サーバの実装

Mac側で動作し、将棋所とやねうら王を仲介するためのサーバをnode.jsで実装しました。

将棋所から出力されたメッセージ(例: "usinewgame")は、サーバの標準入力に与えられるため、それを読み取って lines 変数にためておきます。やねうら王からHTTPリクエストが来たら、lines変数の内容をレスポンスで返します。やねうら王から出力されたメッセージ(例: "bestmove")がHTTPリクエストで得られたら、console.logで標準出力に出力し、将棋所に読み取らせます。初期の実験では、1回のHTTPリクエストでコマンド1行のみ送信するようにしていましたが、レイテンシが大きすぎました。送信したいメッセージを一度キューにためて、通信時には一括で送る仕組みにする必要がありました。

const express = require('express');
const readline = require("readline");
const app = express()

process.stdin.setEncoding("utf8");

let lines = [];
const reader = readline.createInterface({
  input: process.stdin,
});

reader.on("line", (line) => {
  //改行ごとに"line"イベントが発火される
  lines.push(line); //ここで、lines配列に、標準入力から渡されたデータを入れる
});


// req.bodyにPOSTデータを入れる設定
app.use(express.raw({ type: '*/*', limit: '128kb' }));

const port = 4090;
const server = app.listen(port, () => {
  console.error(`Server listening on port ${port}`);
});

app.post('/write', (req, res)=> {
  let text = req.body.toString('utf-8');
  let lines = text.split('\n');
  for (const line of lines) {
    console.log(line);
  }
  res.send('1');
  res.end();
});

app.post('/read', (req, res)=> {
  const quit = lines.some((v) => v == "quit");
  const line = lines.join('\n');
  lines = [];
  res.send(line);
  res.end();

  if (quit) {
    // クライアントがquitを読み次第、サーバプロセスを終了する
    setTimeout(() => {
      server.close();
    }, 100);
  }
});

reader.on("close", () => {
    // stdinが閉じられたら終了。ただし、将棋所は対局が終わっても閉じてくれない
    console.error('stdin closed');
    server.close();
});

動作結果

floodgateに投入して実験しました。

やねうら王(水匠5評価関数)

NNUE系評価関数を用いるやねうら王の場合の結果です。33局対戦し、レート3873となりました。探索速度は2スレッドで約55万NPSでした。ハッシュサイズは64MBです。

Apple Watch Series 9+やねうら王のfloodgate対局結果

ふかうら王

DL系評価関数を用いるふかうら王も同様にビルドできました。fukauraouブランチにあります。10ブロック(20層)192チャンネルのモデルを動作させました。

探索速度は約450NPSでした。同じモデルでiPhone 15 Proでは約5000NPSとなっています。Apple Watchにもなぜか機械学習用チップのNeural Engineが搭載されているため、機体の大きさの割には高速に動作します。動作するものの、floodgate上でレートが付与されるまで対局を続けることができませんでした。不規則なタイミングでメモリ不足によってクラッシュしてしまう問題があったためです。探索ツリーを保持するメモリ容量が1000ノード=1MB程度必要で、ノード数制限を25000にしておけばメモリが基本的に足りることはわかったのですが、時々これより少ないノード数の時点でクラッシュしてしまいました。探索木を成長させながら逐次的にメモリを確保する仕組みとなっているため、バックグラウンドアプリがメモリを要求したタイミングでメモリ確保に失敗してしまうのかもしれません。これ以上のデバッグが困難なため、ふかうら王の運用は諦めました。やねうら王ではハッシュ表のメモリ全体を事前に確保して動的確保しないため、クラッシュしづらいのではないかと予想しています。

運用ノウハウ

floodgateで対局するにあたり、2点の運用ノウハウが必要でした。

1点目は、Apple Watchをスリープ状態にさせないことです。通常は操作がないと15秒(設定により70秒)で画面が消灯し、アプリも停止してしまいます。しかし、Xcodeからアプリをデバッグ実行している最中は長時間経ってもスリープしないため、この状態でなら手動操作なしにfloodgateでの対局が可能でした。デバッグ実行以外の手段は試せていませんが、Apple Watchで動画を視聴できるアプリがあり、これを試したところ2分以上無操作でも動画が再生され続けたため、何らかの回避策はあるかもしれません。

2点目は冷却です。やねうら王を動作させると発熱し、それが原因で充電器に接続していても充電が止まってしまいます。

Apple Watchの温度が理由で充電は保留されました」の表示

これを回避するため、サーキュレータの上に充電器ごと載せることで、冷却は十分になりました。

サーキュレータで冷却しながら運用する様子

また、バッテリーの設定で充電上限の最適化をオフにすることで、バッテリー残量に関わらず充電されるようになります。

「充電上限の最適化」をオフ

ただし、この設定をしていても対局中にどんどんバッテリーが減ってしまう場合があり、最終的に電池切れになる場合がありました。一方で対局中でも充電残量が上がっていく場合もあり、充電の制御については不明な点が残ります。

残る課題として、HTTP通信が一度失敗してしまうと将棋所との同期がとれなくなり対局続行不可能となる事例がありました。通信部分はよりロバストかつ低レイテンシにすることが望ましいです。単純なHTTPポーリングではなく、ロングポーリング技術を採用することが考えられます。

まとめ

Apple Watch上でやねうら王をどうさせることができました。ビルド手段はiOSと似ていましたが、通信はTCPソケットが使えないため、HTTPで代用する必要がありました。冷却により、floodgate上での連続対局が可能であり、レート3873が付与されました。ふかうら王については動作するものの不安定で、連続対局は困難でした。

動作環境や安定性の面からネタ枠の域を出ませんが、スマートウォッチでも十分強い将棋AIが動作することを示せました。6月の電竜戦TSECは、これで出るかもしれません。