Slackの家庭内日記をはてなブログに同期していく

夫婦で日記の習慣を始めたのは3年ほど前。簡単な記録でもつけておくと後で色々見返せて良いよね、と。

とはいえなかなか習慣をつけるのは難しいなと思って

  • ブログサービスは最も日付と関連づけて文書が保存されるし良い
  • しかしブログ投稿、となるとちょっと気合いが要る
  • SNS感覚で気軽に投稿できると良い
  • が、下手なSNS誤爆したりすると大変

といったものがあり、色々試行錯誤もした結果 家庭内Slackで日記用channelを作り、1日1投稿ずつ書き込む、という運用にしたのだった。

  • 誤爆の心配は少ない (少なくとも妻は家庭以外でSlackを使わないし)
  • 専用channelで投げていくだけなので他の話題と混ざったりもしない
  • 気軽に書けて編集なども簡単
  • 検索もそれなりにできて過去の記録を見返しやすい

ということで気に入っていた。

ところが90日以上前のものが見られなくなるということで移行を検討した。 べつに課金すれば良いだけではあるのだけど、せっかくならバックアップも兼ねて別のブログに同期しておいたら良いのでは、と。

ので、過去の日記投稿たちをSlackから取得してブログ記事として投稿していく、というものを作った。

なんとなく書きやすいのはPythonかな?と思ってPythonで。ライブラリも充実していて数十行程度でそれぞれのロジックが書けてラクだったので正解だったと思う。

APIでの取得と投稿

SlackのAPIからchannelの履歴を遡り取得していく。 text要素だけ取ると絵文字が :bow: などで表示されてしまうので rich_text_section から復元していってSlackに書かれた通りの文字列でとれるようにした。

同期先のブログははてなブログにした(自分が使っていて多少は勝手が分かるので)。自分と妻のアカウントだけが見られる限定公開にして、そこに AtomPubAPIで投稿していく。 updated を指定すれば日時を指定できるので過去のものも過去の日記として記録されていく。

同期をするタイミングは確定していないけど、複数回実行するし 一度投稿したものも編集する可能性はゼロではないので、既に記事があるものは PUT で更新、まだ無いものは POST で新規投稿する。が 投稿しようとしているものが既に記事あるかどうかを調べるのが難しく、ドキュメントには entry_id がepochと書いてあるけどそうでもない… まぁ投稿日時が一致するようにするから updated に使う日時と同じ epoch の記事が既にあるかどうかを調べれば良いか、ということでそうした。

はてなブログの制限

いざ過去の日記をどんどん記事として投稿していこうとしたら50日ぶんくらいで止まってしまった。 知らなかったけどどうやらはてなブログには「1日100件まで」という上限があるらしい…。 まぁ3年分なら毎日コツコツやっていけば 365 * 3 * 2人 = 2190件 だから約21日で終わるはず。ギリギリ8月中には。。

もっと急ぎで大量の記事を移したかったら インポートできる形式のファイル を作るとかすることになるんだろうか。

定期実行

過去のはボチボチやっていくとして、今後これから書くものをどうするか。 すっかり3年の習慣になっているので今からブログに全面移行するつもりもなくて、日記は今まで通りにSlackで書き続けて、定期的に直近の日記をブログに同期していけば良い、ということにした。

Cloud Functionsにdeployして Cloud Schedulerから定期実行して Pub/Sub経由で自動で定期実行してくれるように設定。これであとは勝手に同期されるようになってくれるはず。

書くときはSlackに書き続け、90日以上前のものは調べたり検索したりできなくなるけど そのときはブログの方で検索すればもっと昔まで遡ることができる、という運用。

まとめ

まだ完全に同期に成功しているわけではないけど、この運用でしばらくやってみる予定。そもそも家庭内の日記がどこまで続くか…という問題もあるけど。まぁ夫婦ともに3年くらい続いているわけだし しばらくは続くんじゃないかな? 子育てで大変な日々も数年後には笑って読み返せるといいな

Rustで将棋棋譜変換ライブラリを作った

というわけで作った記録

将棋棋譜の形式色々

プロ対局、コンピュータ将棋、対戦アプリ、…と様々な将棋対局についてのデータが世の中には存在しているが、その形式は様々だったりする。

KIF

将棋ソフト「柿木将棋」 で使われている形式だそうで、現在でも多くの将棋ソフトなどで使われているようだ。プロ対局の中継サイトなどでもこの形式が使われている。

例:

# ---- Kifu for Windows95 V3.53 棋譜ファイル ----
開始日時:1999/07/15(木) 19:07:12
終了日時:1999/07/15(木) 19:07:17
手合割:平手
先手:先手の対局者名
後手:後手の対局者名
手数----指手---------消費時間-- # この行は、なくてもいい
1 7六歩(77) ( 0:16/00:00:16)
2 3四歩(33) ( 0:00/00:00:00)
3 中断 ( 0:03/ 0:00:19)

初期盤面や指し手など日本語で記述されていて、人間が読むぶんには直感的に分かりやすい。

が、コンピュータで解析するにはなかなか厄介…。 書式の定義については以下のリンクだけが存在している状態のようで、これを頼りに解析していくしかない。しかし「省略できる」「任意のものを追加できる」など なかなか自由な項目が多くて対応するのは大変。

優れている点としては、上記リンクでは言及されていないが「指し手の変化・分岐が表現できる」、という点で、枝分かれしたものも含めてすべて記録として残せるようになっているようだ。自分は柿木将棋などのシリーズ製品を持っていないけど、おそらくこれらがちゃんと分岐を含む棋譜も読み取って再現できるようになっていると思われる。

CSA

これは Computer Shogi Association で定めている形式のようで、KIFと比較すると圧倒的にコンピュータで解析しやすい形になっている。

例:

'----------棋譜ファイルの例"example.csa"-----------------
'バージョン
V2.2
'対局者名
N+NAKAHARA
N-YONENAGA
'棋譜情報
'棋戦名
$EVENT:13th World Computer Shogi Championship
'対局場所
$SITE:KAZUSA ARC
'開始日時
$START_TIME:2003/05/03 10:30:00
'終了日時
$END_TIME:2003/05/03 11:11:05
'持ち時間:25分、切れ負け
$TIME_LIMIT:00:25+00
'戦型:矢倉
$OPENING:YAGURA
'平手の局面
P1-KY-KE-GI-KI-OU-KI-GI-KE-KY
P2 * -HI *  *  *  *  * -KA * 
P3-FU-FU-FU-FU-FU-FU-FU-FU-FU
P4 *  *  *  *  *  *  *  *  * 
P5 *  *  *  *  *  *  *  *  * 
P6 *  *  *  *  *  *  *  *  * 
P7+FU+FU+FU+FU+FU+FU+FU+FU+FU
P8 * +KA *  *  *  *  * +HI * 
P9+KY+KE+GI+KI+OU+KI+GI+KE+KY
'先手番
+
'指し手と消費時間
+2726FU
T12
-3334FU
T6
%CHUDAN
'---------------------------------------------------------

主にASCII文字で表現されるようになっていて 駒種は2文字、場所を示すのも2桁の数字、といった一定の決まりがあるので解析しやすい。 これと同様の表現を用いたCSAサーバプロトコルがあり、世界コンピュータ将棋選手権などではこれが使われているらしい。

USI/SFEN

これは棋譜形式とはちょっと違うかもしれないけど、 将棋GUIソフト「将棋所」 で使われて広く普及しているUSIという通信プロトコルがある。 GUIと思考エンジンの間の通信をするために局面情報や指し手の情報を SFEN(Shogi Forsyth-Edwards Notation)という形式で表現していて、これによって棋譜を再現できる。

例:

position sfen lnsgkgsnl/9/ppppppppp/9/9/9/PPPPPPPPP/1B5R1/LNSGKGSNL w - 1 moves 5a6b 7g7f 3a3b

文字表記

そもそもコンピュータで扱われる前は文字情報だけで記録が残っているはずで、これは指し手をそれぞれ数字と駒種と相対位置や動作で表現している。KIF形式はこれらの表現をカスタマイズした形式といえる。

76歩
52銀右上成

その他

その他にも幾つかの形式があるようで、どれがどのくらい普及しているのかよく分からない…。 おそらく多くはKIFかCSAが使われていると思われる。CSAはむしろコンピュータ将棋の人にしか使われていないか…?

GUIソフトや将棋ソフトの開発者はそれぞれこれらの形式を読み書きできるように対応していくしかない、という状況のようだ…

JSON棋譜フォーマット(JKF, json-kifu-format)

上述した棋譜形式はそれぞれ読みやすさや表現できる情報が違っているわけだが、実際のところ指し手には多くの情報が必要で、単独の指し手の表記だけを取り上げたときに「どの駒が」「どこから動いて」「どうなったか」などの情報をすべて知ることは出来ない。直前の指し手や現在の盤面の情報がないと分からないものが多かったりする。

そこで、json-kifu-formatという形式が提案されて公開されている。

github.com

READMEに書かれている通り、各指し手について必要な情報をすべて含めたJSON形式で表現し、また分岐にも対応している。

例:

{
  "header": {
    "先手": "na2hiro",
    "後手": "うひょ"
  },
  "moves": [
    {},
    {"move":{"from":{"x":7,"y":7},"to":{"x":7,"y":6},"color":0,"piece":"FU"}},
    {"move":{"from":{"x":3,"y":3},"to":{"x":3,"y":4},"color":1,"piece":"FU"}},
    {"move":{"from":{"x":8,"y":8},"to":{"x":2,"y":2},"color":0,"piece":"KA","capture":"KA","promote":false}},
    {"move":{"from":{"x":3,"y":1},"to":{"x":2,"y":2},"color":1,"piece":"GI","capture":"KA","same":true}},
    {"move":{"to":{"x":4,"y":5},"color":0,"piece":"KA"}},

    {"special": "CHUDAN"}
  ]
}

さらにこの形式の提案だけでなく、KIFやCSAからJKF形式に変換するためのparserと、JKFを再生するためのプレイヤーも同梱している。

実装としては それぞれの棋譜形式を一度parseしたのち、初期局面から一手ずつ実際に進めて足りない情報を補っていくことで最終的なJKFを生成しているようだ。

自作parser, converter

上記 json-kifu-parser はJavaScriptで実装されているが、自分は最近はRustで将棋関連のものを作っていてRustでも同様のものが欲しいと思った。

具体的には、自作の詰将棋ソルバ に色々な問題を食わせてみようと思ったときにKIFファイルが多かったり 自分で書くにはCSAで書いたりで色々な入力に対応したいというのがあった。

CSAのparseには csa crateが既に存在していて問題ないが、KIFは自力でやるしかなく、いちおう自前である程度のparseできるところまで作っていたが、折角だから分岐もちゃんと対応してJKFに互換のデータを作れるParserを作ってみよう、という気持ちになった。 ので作ることにした。

github.com

KIFのparseには csa と同様に nom というparser combinators libraryを使って頑張って実装した。optmany0 が多くてなかなか大変だったが、どうにか様々なケースに対応できたと思う。 パフォーマンス的にちょっと問題があるかもしれないけど… 大量の棋譜を食わせて高速に処理をしなければならない、という場面でない限りは大丈夫か。

分岐は一度最後まで読み込んでstackに積んでおいてから後で forks に詰め替えていく必要があってなかなか頭を使った。

CSAのparseはcsa crateに任せてparse結果からJKFに変換する処理だけを書いて作ったが、csa crateは指し手のコメントなどを捨ててしまっていて復元できなかったりもあるので、こちらも自分で実装しなおしても良いのかもしれない。

それぞれparseして結果を JsonKifuFormat に詰めたら、足りない情報を補うために normalize() を呼ぶ。 この中で実際に局面を動かして確かめる必要があるので内部で shogi_core crateを使った。forks再帰的に処理していく。

また、 relative 情報は他の同種駒と比較して相対的なものを調べる必要があり、計算が大変めんどう…なので shogi_official_kifu crateで表記結果から取り出して詰めることでお茶を濁した。本来ならこの棋譜表記を作成するための情報なので本末転倒な感もあるが…。そもそも relative はそういった棋譜表記をするときくらいにしか使わない(棋譜変換などでは必要ない)ので計算もoptionalで良いのかもしれない。

一度 JsonKifuFormat が作れれば、そこからKIF形式やCSA形式に書き出すのは比較的容易にできる。(KIFの分岐はまだ対応できていないが…)。 また shogi_core::Position にも変換できるし shogi_core::ToUsi traitを利用してUSIで書き出すのもやりやすい。

ので、もし対応する棋譜形式を増やそうと思ったらそれぞれ JsonKifuFormat と相互変換するためのparser/converterだけを用意していけば良い、ということになる。 記事冒頭に載せた図の通りで、変換の中継のための形としてとても良いデータ型になっていると思う。

クレート公開

せっかく便利ライブラリとして実装できたので、(需要はまったく無いかもしれないが)crates.ioに公開してみることにした。 できるだけドキュメントをちゃんと書いて、初の cargo publish

docs.rs

Rustで将棋棋譜を扱いたい方が居たら使ってみていただけると嬉しいです。

WASM化してブラウザ上でclient sideだけで棋譜変換できるWebApp、なら多少は使いどころあるかな…?

SIMDによる将棋Bitboard計算の高速化

ということでSIMDでの高速化のメモ。

SIMDとは

ja.wikipedia.org

の通り、複数のデータを1命令で同時に演算する、というもの。 将棋Bitboardは81マスのデータを表現するのに64bitでは足りないので、一般的に [u64; 2] など複数の要素を使うことになる。 このBitboard同士の論理演算を普通に実装しようとすると、

struct Bitboard([u64; 2]);

impl std::ops::BitAnd for Bitboard {
    type Output = Self;

    fn bitand(self, rhs: Self) -> Self::Output {
        Self([self.0[0] & rhs.0[0], self.0[1] & rhs.0[1]])
    }

}

のように各要素同士をそれぞれ演算した結果を新たに格納する、という操作が必要になる。 こういった場面でSIMDを利用することで、u64同士の2つの演算を1命令で同時に行うことができる。

2命令が1命令に減る程度ではあるが、合法手生成のための駒の利きの計算などで多くの論理演算を行っているので、計算の高速化のためには重要なものになる。

実装

defaultの実装にしても良いのかもしれないが、とりあえずは features として定義した。

[features]
simd = []

cargo build --features simd など指定したときのみ有効になり、これが指定されなかったり 対応する実装が存在していない場合は、SIMDを使わない shogi_core::Bitboard を使用する実装にfallbackするようにしている。

x86_64

まずはx86_64のものを実装した。128bitレジスタを利用するSSE命令をメインに、一部でAVX2を使用して256bitレジスタでの計算も行っている。

use std::arch::x86_64;

pub(crate) struct Bitboard(x86_64::__m128i);

SIMD Bitboard by sugyan · Pull Request #17 · sugyan/yasai · GitHub

基本演算

前述したような論理演算については x86_64::_mm_and_si128, x86_64::_mm_or_si128, x86_64::_mm_xor_si128 でそれぞれ128bitの計算を1命令で出来るのでそれを使う。

同値判定、ゼロ値判定には x86_64::_mm_test_all_zerosi32 の値を返してくれるのでこれで判定できる。

飛び利き計算

前回の記事 でLeading/Trailing Zerosを使う方法について書いたが、このLeading/Trailing Zerosを求めるためには一度SIMDレジスタから何らかの形で値を取り出す必要があり、SIMDとはあまり相性が良くないようだった。ので極力使わずに論理演算のみで解決する方向を模索した。

まずは一番簡単な後手香車の利き。decrementして xor とる、という方法。 1〜7筋か/8,9筋か、で対応する u64 を取り出して -1 するのが一番命令数少なくて済みそうではあるが、分岐をなくして論理演算だけで計算するようにしてみた。

 impl Bitboard {

    ...

   fn sliding_positive_consecutive(&self, mask: &Self) -> Self {
        unsafe {
            let and = x86_64::_mm_and_si128(self.0, mask.0);
            let all = x86_64::_mm_cmpeq_epi64(self.0, self.0);
            let add = x86_64::_mm_add_epi64(and, all);
            let xor = x86_64::_mm_xor_si128(and, add);
            Self(x86_64::_mm_and_si128(xor, mask.0))
        }
    }
 }

_mm_cmpeq_epi64 に同値を渡しすべて1になっているものを作り、それを足すことで両方を同時に1減じる。 ここではmaskがある筋の連続的なbitしか渡ってこない前提であり、最終的にこのmaskとの論理積をとるので関係ない部分まで変化してしまっても問題ない。

先手香車はQugiyの手法が最もシンプルで良さそうだったのでそのまま使わせていただいた。

impl Bitboard {

    ...

    fn sliding_negative_consecutive(&self, mask: &Self) -> Self {
        unsafe {
            let m = x86_64::_mm_and_si128(self.0, mask.0);
            let m = x86_64::_mm_or_si128(m, x86_64::_mm_srli_epi64::<1>(m));
            let m = x86_64::_mm_or_si128(m, x86_64::_mm_srli_epi64::<2>(m));
            let m = x86_64::_mm_or_si128(m, x86_64::_mm_srli_epi64::<4>(m));
            let m = x86_64::_mm_srli_epi64::<1>(m);
            Self(x86_64::_mm_andnot_si128(m, mask.0))
        }
    }
}

飛車・角行の飛び利きは、4方向をそれぞれ(Squareのindex的に)正方向のものと負方向のもので分けて2つずつ計算する。 本当は飛車の縦方向は香車の利きを再利用するのが良いのだとは思うけど、両駒の利きを同じinterfaceで計算できるようにするためにそれはあえてしなかった。

正方向は前述のdecrementしてxor取るものを、ちゃんと桁借りを考慮して128bit全体のdecrementになるようにした上で、AVX2の256bitレジスタを使って2方向分を同時に計算する、という形に。

impl Bitboard {

    ...

    fn sliding_positives(&self, masks: &[Self; 2]) -> Self {
        unsafe {
            let self256 = x86_64::_mm256_broadcastsi128_si256(self.0);
            let mask256 = x86_64::_mm256_set_m128i(masks[0].0, masks[1].0);
            let masked = x86_64::_mm256_and_si256(self256, mask256);
            // decrement masked 256
            let all = x86_64::_mm256_cmpeq_epi64(self256, self256);
            let add = x86_64::_mm256_add_epi64(masked, all);
            let cmp = x86_64::_mm256_cmpeq_epi64(add, all);
            let shl = x86_64::_mm256_slli_si256::<8>(x86_64::_mm256_xor_si256(cmp, all));
            let dec = x86_64::_mm256_sub_epi64(add, shl);
            // (masked ^ masked.decrement()) & mask
            let xor = x86_64::_mm256_xor_si256(masked, dec);
            let ret = x86_64::_mm256_and_si256(xor, mask256);
            Self(x86_64::_mm_or_si128(
                x86_64::_mm256_castsi256_si128(ret),
                x86_64::_mm256_extracti128_si256::<1>(ret),
            ))
        }
    }
}

負方向はおそらくQugiyのように swap_bytes して unpack して桁借りを計算して… というのが良いのだと思いつつも、前述した leading_zeros を使うのが気に入っていたのでこれを応用することにした。

SIMDレジスタ上での leading_zeros の正確な値は求めづらいが、「leftmost set bitが含まれる8bit」がどこかは見つけやすい。 _mm256_movemask_epi8 を使って、各8bitにおける最上位の値を集めた u32 を得ることができる。 ので、 _mm256_cmpeq_epi8_mm256_setzero_si256 と比較した結果に対してこのmovemaskをかけてやれば、「各8bitにおいて値が 0 ではなかったもの」をbit列で得ることが出来る。あとは16bitずつ上位と下位で切り出して反転させてから leading_zeros をとれば、どの8bitに leftmost set bit があったかを知ることができる。 ということでその8bit以降を全部塗り潰すmaskだけ用意しておく。

あとは先手香車のように >>(shift right) と or を3回だけ繰り返すことで、各8bit内で leftmost set bit 以降をすべて 1 にすることは出来るので、それと上述のmaskの論理和をとれば「leftmost set bit以降がすべて 1 になっているもの」を作り出すことはできる。

const MASKED_VALUES: [(i64, i64); 16] = [
    (0, 0),
    (0x0000_0000_0000_00ff, 0),
    (0x0000_0000_0000_ffff, 0),
    (0x0000_0000_00ff_ffff, 0),
    (0x0000_0000_ffff_ffff, 0),
    (0x0000_00ff_ffff_ffff, 0),
    (0x0000_ffff_ffff_ffff, 0),
    (0x00ff_ffff_ffff_ffff, 0),
    (-1, 0),
    (-1, 0x0000_0000_0000_00ff),
    (-1, 0x0000_0000_0000_ffff),
    (-1, 0x0000_0000_00ff_ffff),
    (-1, 0x0000_0000_ffff_ffff),
    (-1, 0x0000_00ff_ffff_ffff),
    (-1, 0x0000_ffff_ffff_ffff),
    (-1, 0x00ff_ffff_ffff_ffff),
];

impl Bitboard {

    ...

    fn sliding_negatives(&self, masks: &[Self; 2]) -> Self {
        unsafe {
            let self256 = x86_64::_mm256_broadcastsi128_si256(self.0);
            let mask256 = x86_64::_mm256_set_m128i(masks[0].0, masks[1].0);
            let masked = x86_64::_mm256_and_si256(self256, mask256);

            let eq = x86_64::_mm256_cmpeq_epi8(masked, x86_64::_mm256_setzero_si256());
            let mv = x86_64::_mm256_movemask_epi8(eq) as u32;
            let e0 = MASKED_VALUES[15 - (mv ^ 0xffff_ffff | 0x0001_0000).leading_zeros() as usize];
            let e1 = MASKED_VALUES[31 - (mv & 0xffff ^ 0xffff | 0x0001).leading_zeros() as usize];

            let m = masked;
            let m = x86_64::_mm256_or_si256(m, x86_64::_mm256_srli_epi16::<1>(m));
            let m = x86_64::_mm256_or_si256(m, x86_64::_mm256_srli_epi16::<2>(m));
            let m = x86_64::_mm256_or_si256(m, x86_64::_mm256_srli_epi16::<4>(m));
            let m = x86_64::_mm256_or_si256(
                x86_64::_mm256_srli_epi16::<1>(m),
                x86_64::_mm256_set_epi64x(e0.1, e0.0, e1.1, e1.0),
            );
            let ret = x86_64::_mm256_andnot_si256(m, mask256);
            Self(x86_64::_mm_or_si128(
                x86_64::_mm256_castsi256_si128(ret),
                x86_64::_mm256_extracti128_si256::<1>(ret),
            ))
        }
    }
}

これでとりあえずは同時に2方向ずつの飛び利きを求めることはできた。

AArch64

x86_64用のSIMDを頑張って実装したものの、じつは手元のメインの開発機としては最近M1 MBPを使っている。

のでこちらでもSIMDを使えるようにしたいと思い、NEONを使って同様に128bit計算を同時に行うように実装してみた。

use std::arch::aarch64;

pub(crate) struct Bitboard(aarch64::uint64x2_t);

SIMD Bitboard for AArch64 NEON by sugyan · Pull Request #19 · sugyan/yasai · GitHub

だいたいx86_64と同じように出来るかな…と思ったが まぁまぁ違うところがあって苦戦した。

同値判定、ゼロ値判定

x86_64::_mm_test_all_zerosi32 で値を返してくれたが、 aarch64 にはそれに相当するようなものは無い。 aarch64::vceqq_u64 なり aarch64::veorq_u64 なりで2値の比較をしても、いちいちその結果から中身を取り出して値を確認しないと bool を返すことはできない。

検索して調べてみたが、どうやら aarch64::vqmovn_u64 を使って3命令でゼロ値判定するのが最良のようだった。

impl Bitboard {

    ...

    pub fn is_empty(&self) -> bool {
        unsafe {
            let vqmovn = aarch64::vqmovn_u64(self.0);
            let result = aarch64::vreinterpret_u64_u32(vqmovn);
            aarch64::vget_lane_u64::<0>(result) == 0
        }
    }
}

VQMOVN (Vector Saturating Move and Narrow) copies each element of the operand vector to the corresponding element of the destination vector. The result element is half the width of the operand element, and values are saturated to the result width.

https://developer.arm.com/documentation/dui0489/h/neon-and-vfp-programming/vmovl--v-q-movn--vqmovun

とのことで、bit幅を半分にし 値がオーバーフローする場合は飽和させてコピーする、というものらしい。 以下の記事が詳しくて分かりやすかった。

qiita.com

ゼロ値との比較に使うぶんには飽和の影響は無く、正しく 0 か否かを保持したまま uint64x2_t から uint32x2_t に変換できる、ということになる。あとはこれを uint64x1_t として解釈した上でその要素として単一の u64 を取り出して 0 と比較すれば良い。

飛び利き計算

香車の利きはx86_64と同様にQugiyの手法で問題なかったが、飛車・角行の利きには同じことをしようにも x86_64::_mm256_movemask_epi8 と同等なものが見つからなかった。 各8bitから値をかき集めて単一の u32(もしくは u16)を得る、のようなことが簡単には出来ないらしい。

また、 x86_64::_mm_slli_si128 のようにlaneを跨いだbit shiftも出来ないようだった。まぁdecrementの桁借り計算で必要なのは64bitのlane丸ごと移動だから aarch64::vextq_u64 でどうにかはなるのだけど…

ここはまぁ仕方ないか、ということで前回の記事で書いた Leading/Trailing Zeros を使う手法で実装した。毎回 [u64; 2] の値を取得して分岐して…というのが必要になるのは微妙ではあるが そこまで遅いというわけでもないと思う。

use std::mem::MaybeUninit;

const MASKED_VALUES: [[u64; 2]; Square::NUM + 2] = {
    let mut values = [[0; 2]; Square::NUM + 2];
    let mut i = 0;
    while i < Square::NUM + 2 {
        let u = (1_u128 << i) - 1;
        values[i] = [u as u64, (u >> 64) as u64];
        i += 1;
    }
    values
};

impl Bitboard {

    ...

    #[inline(always)]
    fn values(self) -> [u64; 2] {
        unsafe {
            let m = MaybeUninit::<[u64; 2]>::uninit();
            aarch64::vst1q_u64(m.as_ptr() as *mut _, self.0);
            m.assume_init()
        }
    }
    fn sliding_positive(&self, mask: &Bitboard) -> Bitboard {
        let m = (*self & mask).values();
        let tz = if m[0] == 0 {
            (m[1] | 0x0002_0000).trailing_zeros() + 64
        } else {
            m[0].trailing_zeros()
        };
        Self(unsafe {
            aarch64::vandq_u64(
                mask.0,
                aarch64::vld1q_u64(MASKED_VALUES[tz as usize + 1].as_ptr()),
            )
        })
    }
    fn sliding_negative(&self, mask: &Bitboard) -> Bitboard {
        let m = (*self & mask).values();
        let lz = if m[1] == 0 {
            (m[0] | 1).leading_zeros() + 64
        } else {
            m[1].leading_zeros()
        };
        Self(unsafe {
            aarch64::vbicq_u64(
                mask.0,
                aarch64::vld1q_u64(MASKED_VALUES[127 - lz as usize].as_ptr()),
            )
        })
    }
}

Iterator

しかしどうも実装して実際にbenchmarkを取ってみると、すごく遅い。SIMD使わない方が速いくらい。一体何故…?と思って調べてみると、Bitboardの計算自体ではなく、そこから Square を取り出す処理が遅くなっているようだった。

合法手の生成にはそれぞれ利きの通る位置への移動を列挙したりするので、論理演算から得たBitboardからすべての対応する Square を列挙するような操作が多くなる。 これには Iterator を実装し その内部で fn pop(&mut self) -> Option<Square> を呼ぶのが初期の実装だった。

impl Bitboard {
    pub fn pop(&mut self) -> Option<Square> {
        let mut m = unsafe {
            let mut u = std::mem::MaybeUninit::<[u64; 2]>::uninit();
            aarch64::vst1q_u64(u.as_mut_ptr() as *mut _, self.0);
            u.assume_init()
        };
        if m[0] != 0 {
            unsafe {
                let sq = Some(Square::from_u8_unchecked(Self::pop_lsb(&mut m[0]) + 1));
                self.0 = aarch64::vsetq_lane_u64::<0>(m[0], self.0);
                sq
            }
        } else if m[1] != 0 {
            unsafe {
                let sq = Some(Square::from_u8_unchecked(Self::pop_lsb(&mut m[1]) + 64));
                self.0 = aarch64::vsetq_lane_u64::<1>(m[1], self.0);
                sq
            }
        } else {
            None
        }
    }
    fn pop_lsb(n: &mut u64) -> u8 {
        let ret = n.trailing_zeros() as u8;
        *n = *n & (*n - 1);
        ret
    }
}

impl Iterator for Bitboard {
    type Item = Square;

    fn next(&mut self) -> Option<Self::Item> {
        self.pop()
    }
}

内部の値の trailing_zeros() で rightmost set bit の位置を探して Square を作り、その後そのbitだけを 0 にclearする。これを値がすべてゼロになるまで繰り返す。

とはいえ trailing_zeros() を得るのはSIMDでは出来ないのでどうしても一度 u64 の値で読み込む必要はある。各要素で分岐は必要だし、それを pop_lsb で bit clear した値を書き戻す必要もある。

どうもこの読み込みと書き込みを繰り返す操作が、x86_64のときはそこまでオーバーヘッドを気にすることが無かったが AArch64では特に遅くなっていて影響が出ているようだ。 合法手生成でいうと持駒を打つ場所を列挙するときなど、多くの候補がある(すなわち多くの 1 が存在している)Bitboardに対する Square 列挙の繰り返しで影響が大きくなっていることが分かった。

では、SIMDレジスタからの読み込みを1回だけにして、その後はSIMDレジスタを使わずに処理するようにしたらどうか?ということで Iterator 用のstructを用意し、 IntoIterator を実装してそちらに pop の処理を任せる実装にしてみた。

pub(crate) struct SquareIterator([u64; 2]);

impl SquareIterator {
    #[inline(always)]
    fn pop_lsb(n: &mut u64) -> u8 {
        let pos = n.trailing_zeros() as u8;
        *n &= n.wrapping_sub(1);
        pos
    }
}

impl Iterator for SquareIterator {
     type Item = Square;

     // #[inline(always)]
     fn next(&mut self) -> Option<Self::Item> {
        if self.0[0] != 0 {
            return Some(unsafe { Square::from_u8_unchecked(Self::pop_lsb(&mut self.0[0]) + 1) });
        }
        if self.0[1] != 0 {
            return Some(unsafe { Square::from_u8_unchecked(Self::pop_lsb(&mut self.0[1]) + 64) });
        }
        None
    }
}

impl IntoIterator for Bitboard {
    type Item = Square;
    type IntoIter = SquareIterator;

    fn into_iter(self) -> Self::IntoIter {
        unsafe {
            let m = std::mem::MaybeUninit::<[u64; 2]>::uninit();
            aarch64::vst1q_u64(m.as_ptr() as *mut _, self.0);
            SquareIterator(m.assume_init())
        }
    }
}

これで、以前と同様に for sq in bb { ... } のようにfor loopですべての Square 列挙の繰り返しができるし、SIMDレジスタからの読み込みは1回で済むから速くなるはず… と思ったがbenchmarkとってみると効果が無い。。

ところが、上述のコードで // #[inline(always)]コメントアウトしている部分、fn next(&mut self) -> Option<Self::Item>inline(always) attribute をつけると劇的に改善された。どうして…!?

一応、手元の環境で様々な実装を比較しながらbenchmarkを回してみた。

https://gist.github.com/sugyan/03c1e73a253b4591dc9f29f9609ad93e

SIMDを使わずに [u64; 2]Iterator専用structを用意するのが、やはり素朴で最も速いようだ。それと同様の実装をしているはずの aarch64_iterator, x86_64_iterator や、他の pop を呼ぶ実装などはすべて遅い。 同じ実装だが next(&mut self)inline(always) をつけただけの差異しか無い aarch64_iterator_inline, x86_64_iterator_inline は最速に近い値になった。これだけでこんなに変わるものなのか…

https://godbolt.org/z/r3qj898rE

inline(always) の有無による違いも見てみたけどイマイチよく分からない… どういうことなんだろう。

ともかく、これをつけるかどうかで3〜4倍も速度差があることが分かった。x86_64では2.5倍くらいの差しか出ていなくて、この影響に気付けなかったようだ。 とはいえIterator専用structを用意する手法の方が良さそうなのでどちらもこれを採用することにした。

WebAssembly

ここまでやったらwasm32の 128-bit packed SIMD にも対応してやるぞ、ということで同様にやってみた。

use std::arch::wasm32;

pub(crate) struct Bitboard(wasm32::v128);

Implement wasm32 simd128 Bitboard by sugyan · Pull Request #23 · sugyan/yasai · GitHub

std::arch::wasm32APIが扱いやすく、それほど苦労することなく実装できた。 同値判定、ゼロ値判定には wasm32::u64x2_all_truewasm32::v128_any_true が使えるので便利。 SIMDレジスタ間の読み書きオーバーヘッドなどは特に考える必要なさそうで、とにかく命令数を少なめにしておけば良い感じ。素朴に読み込んで Leading/Trailing Zeros を使って飛び利きを計算するようにした。

各moduleで unit test を書いてあるがwasm32の場合の実行の仕方がよく分からなかった。とりあえず Wasmer を入れて、 wasm32-wasi でbuildして実行するようにしてみた。

export RUSTFLAGS="-C target-feature=+simd128" 
cargo clean
cargo build --tests --target=wasm32-wasi --features simd
wasmer run target/wasm32-wasi/debug/deps/yasai-*.wasm

また、これを使って wasm-packSIMDを有効にしたアプリケーションを作ろうとすると wasm-opt が古くてSIMDに対応していないらしく、自前で最新版をinstallして使う必要があるようだった。

ハマりどころはそれくらいだっただろうか。

Benchmark

実際どれくらい効果が出るか、と手元のPCで perft 5 を計測してみると。

x86_64

MacBook Pro (13-inch, 2017, Four Thunderbolt 3 Ports) 3.3 GHz Dual-Core Intel Core i5

$ cargo +nightly bench perft::bench_perft_5

...

test perft::bench_perft_5_from_default       ... bench: 357,959,818 ns/iter (+/- 5,399,589)

$ cargo +nightly bench --features simd perft::bench_perft_5

...

test perft::bench_perft_5_from_default       ... bench: 276,508,910 ns/iter (+/- 1,445,515)

NPS約1.3倍。

AArch64

MacBook Pro (14-inch, 2021) Apple M1 Pro

$ cargo +nightly bench perft::bench_perft_5

...

test perft::bench_perft_5_from_default       ... bench: 194,382,054 ns/iter (+/- 2,067,121)

$ cargo +nightly bench --features simd perft::bench_perft_5

...

test perft::bench_perft_5_from_default       ... bench: 163,265,674 ns/iter (+/- 4,929,186)

NPS約1.2倍。

WebAssembly

これはWebで確かめられるので実際にSafari以外のブラウザで確かめてみていただきたい。 (SafariSIMD対応していなかったの知らなかった…)

手元のChromeでは1.5倍ちかく速くなる。

感想

Benchmarkの数値を見てわかる通り、古いPCでSIMDつけてちょっと速くするより 良いPCに替えた方がSIMD無しでも圧倒的に速いので、まぁ結局はマシンパワーが強い方がつよい。

とはいえこういったBitboardの実装を差し替えるだけで効果が出る高速化というのはやる価値はちゃんとあると思うのでやってみて良かった。勉強になることもたくさんあった。

逆にこのBitboard関連ではこれ以上の高速化はそんなに望めないので、合法手生成をもっと速くしようと思ったらあとは生成ロジックの方をちゃんと見直す必要がある、ということになる。今後はここを頑張っていきたい。

Bitboardでleading/trailing zerosを使って飛び駒の利きを求める

前置き

Rustで将棋の高速合法手生成ライブラリを作り始めていて、

その後 shogi_core というライブラリが出来てきたのでそれを使うように変更したりした。

それまでは基本的に Rust版Apery の実装を参考にしていたが、駒の利きを求めるところで改善したいところがあり、Qugiyの手法を取り入れてみようと検討した。

そこで色々試したり考えているうちに別のやり方を思いついたのでメモしておく。

先にオチを書いておくと、チェスの世界では既出の手法でした。

飛び利き計算いろいろ

基礎知識

基本的にBitboardを使っての駒の利きは表引きすることで求める。 事前に「この位置にいるこの手番のこの駒は、ここに動ける」というのを全パターン計算してあれば、テーブルから引いてくることで一発で移動先が求まる。

struct PieceAttackTable([[Bitboard; Color::NUM]; Square::NUM]);

impl PieceAttackTable {
    fn new() -> Self {
        ... // 事前計算
    }
    pub fn attack(&self, sq: Square, c: Color) -> Bitboard {
        self.0[sq.array_index()][c.array_index()]
    }
}

これを全駒種に対して用意しておけば良い。 …のだが、香車・角行・飛車の3種の「飛び駒」(という名称が正しいのかよく分かっていない) についてはそうはいかない。これらは「最初に他の駒にぶつかるまで一方向にどこまでも動ける」ので、利きの届く範囲は他の駒の位置に依存する。 ので簡単な表引きでは求めることができず、様々な手法が考案されてきていた。

以下、利きを求めるために使われる「盤上に(自陣敵陣問わず)駒が存在している位置」を表すBitboardをOccupancyと呼ぶことにする。

縦型Bitboardでの香車の利き

利きはOccupancy依存とは言っても、考慮すべきは進む方向のSquareだけで良い。一段目にいる後手香車が駒の存在を意識すべきは同じ筋の二段目〜八段目だけ(九段目は居ても居なくてもそこまで届く、と考えられる)なので、7つのSquareにそれぞれ駒が居るか居ないかの情報だけあれば良い。 ということはその7つのSquareの状態は全パターン列挙しても高々128(1 << 7)通りなので、それくらいならテーブルを用意しておいて引ける、という考え方。

pub struct LanceAttackTable(Vec<Vec<Vec<Bitboard>>>);

impl LanceAttackTable {
    const MASK: usize = (1 << 7) - 1;

    fn new() -> Self {
        let mut table = vec![[[Bitboard::empty(); 1 << 7]; Color::NUM]; Square::NUM];
        ... // 事前計算
    }
    pub fn attack(&self, sq: Square, c: Color, occ: &Bitboard) -> Bitboard {
        let index = Self::MASK & ((occ.value() >> ((sq.file() * 9) + 1)) as usize);
        self.0[sq.array_index()][c.array_index()][index]
    }
}

少しテーブルのサイズは大きくなるが、それほど影響ない範囲で事前計算しておける。

ここで、縦型にレイアウトしているBitboardだとOccupancyの状態からindexに変換する際に利点が出てくる。 縦の動きの場合は対象の筋のbitが連続して並ぶので、これを適当にshiftしてmaskかければパターンのindexを簡単に求めることができる。

角行・飛車の利き (Magic Bitboard / PEXT)

そして角行・飛車でも同じように全パターンを列挙して表引きしたいが、香車の場合と違って

  • パターンの数が多く巨大なテーブルが必要、事前計算にも時間がかかる
    • 具体的には 角行が20,224パターン、飛車が495,616パターン
  • 対象bitが連続して並ばないのでindexの計算が容易ではない

といった問題が出てくる。 前者については apery_rustonce_cell crateを使うことで最初に呼ばれたときにだけ初期化の計算する方式をとっている。

後者について。 前述の通り、一直線に並んでいる香車のような動きなら全体をshiftすることで簡単にindexを計算できるが、横や斜めの動きに関しては注目すべきbitが飛び飛びになるので同じようにはできない。 何とかして対象となるmaskの位置のbitだけの値をかき集めてきて数値を得たい。

 .##.#.#. ##.#.#.# | occupancy
 ..#..#.. #..#..#. | mask
(  1  0   1  1  0 )
----------------------
             10110 | f(occupancy, mask)

これをまさしく実現してくれるのが BMI2のPEXT命令。これを使うことで香車の利きのindex算出と同様のことができるので、一発で表引きできるようになる。

ただしPEXTは64bit整数にしか適用できないので、Bitboard内部で2つの64bit整数に分けて状態保持している場合は p[0] | p[1] のようにmergeした上でPEXTにかけるなどの工夫が必要になる。この方法が問題なく動作するためには縦型でも1〜7筋と8,9筋で分ける必要が出てくる、など制限が生まれる。

歴史的にはこのPEXT方式が出てくる前にMagic Bitboardが発明されていた、のかな。PEXTと同様の処理をMagic Numberを乗算することで実現する、というもの。

PEXTの方がMagic Numberを用意する必要がないという利点がある一方、

  • 特定の命令セットに依存することになる
    • 全環境での動作をサポートするには何らかのfallbackは必要
  • 一部のCPUアーキテクチャでは異常に遅いらしい

といった問題はある。また巨大なテーブルが必要という問題は依然として残り続ける。 このあたりは以下の記事などのBitboard関連シリーズがとても詳しく、勉強になりました。

yaneuraou.yaneu.com

Qugiyの手法

近年の将棋ソフトウェアではおそらく前述のMagic Bitboard/PEXTでの表引きが主流となっている(要出典)が、2021年のWCSC31で登場したQugiyが新しい手法を考案して話題になったらしい。以下の記事が詳しい。

yaneuraou.yaneu.com

要するに加減算での桁上げ/桁借りの伝搬を利用して最初に立っているbitまでのmaskを作る、というのが基本概念のようだ。

 (.##.#.#. ##.#.... | occupancy)
  #..#.#.# ..#.#### | !occupancy
  #..#.#.# ..##.... | !occupancy + 1
---------------------  
  ............##### | !occupancy ^ (!occupancy + 1)

減算ならより簡潔で

  .##.#.#. ##.#.... | occupancy
  .##.#.#. ##..#### | occupancy - 1
---------------------  
  ........ ...##### | occupancy ^ (occupancy - 1)

この概念でいうと飛び飛びのmaskになる角行・飛車でも同様に考えることができ、

  .##.#.#. ##.#.... | occupancy
  ..#..#.. #..#..#. | mask
---------------------  
  ..#..... #..#.... | masked = occupancy & mask
  ..#..... #...#### | masked - 1
---------------------
  ........ ...##### | masked ^ (masked - 1)
  ..#..#.. #..#..#. | mask
---------------------
  ........ ...#..#. | mask & (masked ^ (masked - 1))

と、mask上で最初にoccupancyにぶつかるまでの利きをbit演算だけで求めることができる。

ただし、この方法は桁上げ/桁借りが伝搬する方向にしか適用できず、つまり上から下にレイアウトされている縦型Bitboardでは下方向か左方向にしか使えない。ので、上方向や右方向に対してはbyte swapやunpackを使うなどの工夫をしている、とのこと。

あとは4方向にそれぞれ別々に求める必要があるところを、左下方向の2つと右上方向の2つそれぞれをAVX2命令で並列処理するようにしているらしい。

Leading/Trailing Zerosを使う

Qugiyの手法を実装しようとしたものの、もうちょっと簡単に同じようなことを実現できないかな、と考えていて、この方法を思いついた。

「要するに加減算での桁上げ/桁借りの伝搬を利用して最初に立っているbitまでのmaskを作る」というのが肝だったので、つまり最初に立っているbit(rightmost set bit)を見つければそこまでのmaskを作ってかけてやれば良いだけではないか、と。

  .##.#.#. ##.#.... | occupancy
  ..#..#.. #..#..#. | mask
---------------------  
  ..#..... #..#.... | masked = occupancy & mask
  ........ ...#.... | r = rightmost set bit of masked
  ........ ..#..... | r << 1
  ........ ...##### | (r << 1) - 1
  ..#..#.. #..#..#. | mask
---------------------
  ........ ...#..#. | mask & ((r << 1) - 1)

結局、知りたいのはoccupancyなりoccupancy & maskなりの「最初に立っているbitの位置」だった。これは最下位から見る場合はBitboardからSquareを取り出すときにも使われている Trailing Zeros そのもの。

で、同様のことは逆方向でも考えることができて、こちらは Leading Zeros を使ってleftmost set bitを求めることが出来る。作るmaskが反転する必要あるだけ。

  .#..###. ##.#.... | occupancy
  ..#..#.. #..#..#. | mask
---------------------  
  .....#.. #..#.... | masked = occupancy & mask
  .....#.. ........ | l = leftmost set bit of masked
  ......## ######## | l - 1
  ######.. ........ | !(l - 1)
  ..#..#.. #..#..#. | mask
---------------------
  ..#..#.. ........ | mask & !(l - 1)

ということで、実はこんなコードで簡単に両方向の利きを計算することが出来た。

const BB_1A: Bitboard = Bitboard::single(Square::SQ_1A);
const BB_9I: Bitboard = Bitboard::single(Square::SQ_9I);

fn sliding_positive(occupancy: Bitboard, mask: Bitboard) -> Bitboard {
    let tz = (occupancy & mask | BB_9I).to_u128().trailing_zeros();
    mask & unsafe { Bitboard::from_u128_unchecked((1 << (tz + 1)) - 1) }
}
fn sliding_negative(occupancy: Bitboard, mask: Bitboard) -> Bitboard {
    let lz = (occupancy & mask | BB_1A).to_u128().leading_zeros();
    mask & unsafe { Bitboard::from_u128_unchecked(!((1 << (127 - lz)) - 1)) }
}

occupancy & mask がemptyになるとleading/trailing zeroの値がはみ出てしまうので便宜上 端っこのbitを立ててしまって、それぞれ正方向なら .trailing_zeros()、負方向なら .leading_zeros() で最初に立っているbit位置を求められる。あとはその位置から両端までのmaskを作って元のmaskと論理積とったものを返すだけ。両端までのmaskは事前に作っておいて表引きするだけでも良いかもしれない。

ソフトウェア的に実装すると .leading_zeros().trailing_zeros() と比較してコストがかかる処理になったりもするらしいが、プロセッサよっては lzcnt など専用命令があったりもするのでコンパイルオプションなどを適切に使用すればそれほど気にしなくても良さそう…?

実験と考察

ということで、巨大テーブルも必要なく、PEXTのような特定の命令セットに依存することもなく、それほど負荷がかからない処理で飛び利きを計算することが出来た。

自分のライブラリで実装してみたところ、PEXTでの表引きと比較すると少し遅くなる、程度の差異となった。

github.com

やはり4方向それぞれ計算することになるのはPEXTの表引き一発と比較すると遅くはなってしまうと思われる。巨大テーブルが不要になり多少時間かかっていた初期化処理をする必要が無くなったのは恩恵だが…

Qugiyと同様に、同じ方向のものはSIMDで同時に計算する、などの工夫を入れることで多少は改善されるだろうか。

ただこの例ではshogi_coreのインタフェースとして内部の値に触れるのがu128だったからこうしているが、SIMDを使って実装しているとleading/trailing zerosは簡単には求められないかもしれない…。そのへんの相性の悪さもあって今まで採用されてこなかった、とかもあるのだろうか…?

この手法を思いついて、今までこういう手法なかったでしょうか?と開発者Discordで尋ねたところ最初に書いた通りチェスの世界では "Classical Approach" として知られていたものだった。 実装が簡単な反面、速度的には限界があるのかもしれない。

とはいえそこまですごい遅いわけでもなく簡単に実装できる手法ではあるので、知っておいて損は無いと思う。Classicalではあったが自分でこの手法を思いつくことができたのは良かった。

Rust+WASMでつくる、ブラウザ上で動く詰将棋Solver

成果物

局面を編集し指定し、その局面からの詰み手順を探索する。クエリパラメータにSFEN文字列を入れることで初期局面を指定することもできる。(例)

現時点での仕様は

  • 探索結果の指し手はCSAの文字列配列
  • 最短・最善の解を返すとは限らない
  • 探索しきれない場合は5秒で打ち切り

Repository: https://github.com/sugyan/tsumeshogi-solver-wasm

以下はこれが出来るまでの過程のメモ。

Rust製ソルバの改良

昨年末に、Rustでdf-pn探索を実装して詰将棋のソルバを作った。

memo.sugyan.com

ある程度動作していて そこそこの探索もできていたが、まだちょっと遅いので改善したい、という状態だった。

その後、今年に入ってから将棋AIを作成しようと挑戦を始め、その第一歩として合法手生成と局面探索を高速に実行するためのライブラリを自作することになった。

memo.sugyan.com

これは詰将棋ソルバのために作ったわけではなかったが、これを使うことでソルバの方も改善されそうだな、と思ったので組み込んでみることにした。

結果として、簡単なベンチマークで100倍ほど速くなるという予想以上の改善になった。

github.com

探索部の抽象化

以前は shogi crate を使って、shogi::Positionをwrapしたものから合法手の生成や局面ごとのハッシュ値を計算し、それを使って探索するよう実装していた。 当然のことながら shogi に依存した実装になっていたので、これを自作の yasai を使うように書き換える…ところから始めたが、実装を差し替えられるように trait を用意すれば良いことに気付いた。

pub trait Position {
    type M: Copy + PartialEq;

    fn hash_key(&self) -> u64;
    fn generate_legal_moves(&mut self, node: Node) -> Vec<(Self::M, u64)>;
    fn do_move(&mut self, m: Self::M);
    fn undo_move(&mut self, m: Self::M);
}

実際に探索中に必要なのは

  • 各局面に対するuniqueなハッシュ値が取得できる
  • 各局面からルールに則った指し手を列挙でき、各指し手について指し手を適用または戻すことができる

というだけのことだった。 これさえ満たしている型があれば、それを使って探索が実装できる。

shogi crate を使う場合は

use shogi::{Move, Position};

pub struct ShogiPosition(Position);

impl dfpn::Position for ShogiPosition {
    type M = Move;

    fn hash_key(&self) -> u64 {
        ... // 以前の実装のように自前で実装する
    }
    fn generate_legal_moves(&mut self, node: dfpn::Node) -> Vec<(Self::M, u64)> {
        ... // 以前の実装のようにひたすら候補を列挙し合法手チェックしてフィルタリング
    }
    fn do_move(&mut self, m: Self::M) {
        self.0.make_move(m).expect("failed to make move");
    }
    fn undo_move(&mut self, _m: Self::M) {
        self.0.unmake_move().expect("failed to unmake move");
    }
}

yasai を使う場合は合法手生成とZobrist Hashを求める機能がつけてあるので、

use yasai::{Move, Position};

pub struct YasaiPosition(Position);

impl dfpn::Position for YasaiPosition {
    type M = Move;

    fn hash_key(&self) -> u64 {
        self.0.key()
    }
    fn generate_legal_moves(&mut self, node: Node) -> Vec<(Self::M, u64)> {
        let mut moves = Vec::new();
        for m in self.0.legal_moves() {
            if node == Node::And || self.0.is_check_move(m) {
                self.0.do_move(m);
                moves.push((m, self.0.key()));
                self.0.undo_move(m);
            }
        }
        moves
    }
    fn do_move(&mut self, m: Self::M) {
        self.0.do_move(m);
    }
    fn undo_move(&mut self, m: Self::M) {
        self.0.undo_move(m);
    }
}

という感じで簡潔に記述できる。 両方の実装をbackendとして用意しておいて、あとはコマンド実行時にオプションで選択できるようにしておけばすぐに両方を切り替えて実行できる。

$ time ./tsumeshogi-solver --backend shogi '9/9/3pp4/+r2k1p3/2L1+p4/2+R6/B8/B8/9 b 4g4s4n3l14p 1'
9/9/3pp4/+r2k1p3/2L1+p4/2+R6/B8/B8/9 b 4g4s4n3l14p 1: Ok(["7e7b+", "P*8f", "7f7c"])
./tsumeshogi-solver --backend shogi   1.84s user 0.02s system 99% cpu 1.875 total
$ time ./tsumeshogi-solver --backend yasai '9/9/3pp4/+r2k1p3/2L1+p4/2+R6/B8/B8/9 b 4g4s4n3l14p 1'
9/9/3pp4/+r2k1p3/2L1+p4/2+R6/B8/B8/9 b 4g4s4n3l14p 1: Ok(["+7572NY", "-0086KE", "+7673RY"])
./tsumeshogi-solver --backend yasai   0.07s user 0.01s system 95% cpu 0.084 total

現時点で shogi backend を利用すべき場面はほぼ無いとは思うが、指し手の列挙順が異なるために探索順序が変わり、未解決の証明数2重カウント問題に嵌まるような入力に対しては違う挙動になったりするかもしれない。異なる探索ライブラリでの結果がすぐに比較できるのは利点と言える。

さらに Position trait として抽象化したことで dfpn探索のロジック部分からは完全に「将棋」に関する依存が消えたので、理論上はもはや将棋でなく他の類似したボードゲームの場合でも 前述のtraitを実装できるものであれば「どう逃れても最終的に詰ますことが出来るか否か」の探索に使える、ということになる。

探索打ち切りのための拡張

証明数2重カウント問題で無限ループになってしまったり、とにかく長手数や難解な問題の場合など、探索が一向に終わらないことがある。どこかで区切りをつけて「解くのを諦める」ことができる機能が欲しい、と考えた。 分かりやすい区切りが時間経過で、「何秒以上探索続けても終わらなかったら打ち切り」といったもの。

探索を続けるプログラムに対してそういった割り込みを入れられるようにするとなると普通はまず探索処理を並列化・並行化させて 別の処理から何らかのinterrupt信号を送って止められるように… となりそう。 頑張ってそれを実装してみてもよかったのだけど、まずは「決まった時間経過で止める」という制約で考えて、それであれば事前に設定しておけばちょっとした変更だけで実現できた。

反復深化を続ける loop の部分に判定処理を入れて、元の dfpn::Search traitを拡張したものを作った。

     // ノード n の展開
     fn mid(&mut self, hash: u64, phi: U, delta: U, node: Node) -> (U, U) {
         // 1. ハッシュを引く
         ...
         // 2. 合法手の生成
         ...
         // 3. ハッシュによるサイクル回避
         ...
         // 4. 多重反復深化
-        loop {
+        while !self.cancel() {
             ...
         }
     }

あとは最初の探索開始時に Instant::now() を取っておいて、 self.cancel() が呼ばれるたびに経過時間をチェックして閾値を超えていないか判定を入れれば良い。

これによって 探索時間制限を設定可能になり、「timeout Errorを返すこともあるが、n秒以内に必ず何らかの結果を返す」というものを作ることができた。

$ ./tsumeshogi-solver --timeout 1 --backend shogi '9/9/3pp4/+r2k1p3/2L1+p4/2+R6/B8/B8/9 b 4g4s4n3l14p 1'
9/9/3pp4/+r2k1p3/2L1+p4/2+R6/B8/B8/9 b 4g4s4n3l14p 1: Err(Timeout)
$ ./tsumeshogi-solver --timeout 1 --backend yasai '9/9/3pp4/+r2k1p3/2L1+p4/2+R6/B8/B8/9 b 4g4s4n3l14p 1'
9/9/3pp4/+r2k1p3/2L1+p4/2+R6/B8/B8/9 b 4g4s4n3l14p 1: Ok(["+7572NY", "-0086KE", "+7673RY"])

WebAssembly化

何がしたかったかと言うと、WASMにしてブラウザ上で動作するようにしたかった。 terminal上で動くならCtrl+Cで停止できるが ブラウザ上で動かすとなるとそうもいかないので、数秒程度で必ず何らかの結果を返すようにした。

ただ、 std::time::Instant::now() を使っているとwasmで動かすと 'time not implemented on this platform' と出てエラーになってしまうらしい。ブラウザ上で動かす場合は performance.now() のようなもので代用する必要があるようだ。 とはいえWASM専用に作るわけでもないので普通に使うときには std::time::Instant::now() を使いたい…。

こういうケースのために instant crate というものが存在していて、通常のbuildでは std::time::Instant として動作しつつ、wasm-bindgen featureを指定して.wasmをbuildする場合には performance.now() を使うようにしてくれる、らしい。

これだけの対応で無事にWebAssembly化に成功した。

将棋Player (自作WebComponents)

あとは任意の局面を入力できるように、局面編集可能なUIが欲しかった。 JSで実装された将棋playerなどは幾つか公開されていたが、多くは棋譜再生のためのものだったりして、「自由に局面を編集できて、かつその局面情報を何らかの形で取り出せる」という機能を持つJavaScriptのライブラリやコンポーネントというものは見つからなかった。

唯一 理想に近いものが shogi-player だったが、Vue(2)に依存することになるのがイヤで採用を見送った。

となると欲しいものは自分で作れば良いか、ということで Lit でWebComponentsを自作してみた。

まだ必要最低限の機能しか実装できていないが、これを使えば

<shogi-player mode="edit"></shogi-player>

と Custom Elements として簡潔に利用できて、自由に局面編集しつつ その編集結果をeventとして受け取ることが出来る。あとはそれをWebWorkerで動くようにしたwasmに投げるだけ。フロントエンドのJSは数十行程度で実装完了した。

https://github.com/sugyan/tsumeshogi-solver-wasm/blob/d26db73608d93e288905602f11ca8d59fa52c2ac/www/index.js

まとめ

まだソルバとしては未完成ながら、とりあえずWebブラウザ上で動作する詰将棋Solverを作って公開することが出来た。

図らずも、「フロントエンドのUIライブラリ」「バックエンドの探索ロジック」「探索のための合法手ライブラリ」すべてを自作したことになる…。結果的には良いものが出来たと思うので無駄ではなかった、と思う。

Rustで将棋合法手生成、速度改善

memo.sugyan.com

の続き。

前記事時点でだいたい合法手の生成が出来ていたが、速度面でやや問題があった。 perft の計測してみるとRust版Aperyと比較して2倍程度遅い。 だいたい同じ手順になるよう実装しているはずなんだけど どうしてそんなに差がついているのかなぁと気になって、調べながら色々修正し、ようやく遜色ないくらいの速度まで改善できたのでメモ。

改善PR: Improve performance by sugyan · Pull Request #5 · sugyan/yasai · GitHub

build options

まず大事なのが、 PEXT が有効に使えていなかったこと。 前記事で書いた通り bitintr crate を使って簡単に記述できるようにしていたが、これは内部で target_feature attribute で分岐されていて、 bmi2 が有効になっていないと拡張命令が使われず普通のbit演算で逐一処理するようにフォールバックされるようだった。よく分かっていなかった…。

RUSTFLAGS='-C target-feature=+bmi2' といった rustc に渡すコンパイラオプションを指定してやることで PEXT 拡張命令が使われるようになり、手元の環境ではこれだけで数段速くなった。 実際にはCPUによって結果は異なることになると思われ、例えば PEXT 命令が異常に遅い環境の場合はかえってこれを有効にしない方が速い、ということもあるのかもしれない。

あとは Aperyの Cargo.tomllto の設定が書いてあったのに気付いて、真似して使うようにした。

[profile.release]
lto = true

https://doc.rust-lang.org/cargo/reference/profiles.html#lto

具体的にどういうところによく効いてくるのかちょっと理解できていないけど、これもつけるかつけないかで速度に結構差がでることが分かった。

無理に共通化しない

合法手生成の際、自陣の各駒種について移動可能範囲を列挙していくことになるが、当初は記述コード量が少なくなるように 利きテーブルからの表引きも共通インタフェースを用意してすべて共通の処理で行うようにしていた。

    fn generate_all(&mut self, pos: &Position) {
        let target = !pos.pieces_c(pos.side_to_move());
        for &pt in PieceType::ALL.iter().skip(1) {
            self.generate_for_piece(pt, pos, &target);
        }
        ...
    }

    fn generate_for_piece(&mut self, pt: PieceType, pos: &Position, target: &Bitboard) {
        let c = pos.side_to_move();
        let occ = pos.occupied();
        for from in pos.pieces_cp(c, pt) {
            let p_from = pos.piece_on(from);
            let from_is_opponent_field = from.rank().is_opponent_field(c);
            for to in ATTACK_TABLE.attack(pt, from, c, &occ) & *target {
                if to.rank().is_valid_for_piece(c, pt) {
                    self.push(Move::new_normal(from, to, false, p_from, pos.piece_on(to)));
                }
                if pt.is_promotable() && (from_is_opponent_field || to.rank().is_opponent_field(c))
                {
                    self.push(Move::new_normal(from, to, true, p_from.promoted(), pos.piece_on(to)));
                }
            }
        }
    }

これは確かにコードとしては簡潔で済んでいたが、やはり無駄な分岐や判定が含まれることになっていた。

  • 歩兵・香車・桂馬 のみに必要な「行きどころのない段への不成移動」判定
  • 金将玉将・成駒 には不要な「成りの可否」判定
  • 香車・角行・飛車 にのみ必要な occupied bb の使用
  • 玉将・飛車・角行 などどちらの手番でも同じ動きをする駒での手番情報
  • 金将・成駒 どれも同じ動きをするのでまとめて処理できる

どれも一つ一つは数ステップの処理でたいして時間もかからないが、何千万・何億と膨大な局面を探索する際にはこういった差異も馬鹿にできない。 結局はそれぞれの駒種のための「似ているけど、それぞれちょっと違う」関数を用意してそれぞれ無駄のない最小の処理で生成するようにしてあげた方が良いようだ。

また、特に駒数の多い歩兵についてはさらに特殊で、他の駒と違って「前方に1つしか進めない」という特性から、移動先が一意に定まる。

他の駒の場合は「駒の居る位置を求め、それぞれを移動元としたときの移動先候補を列挙し、それらから自軍駒の位置を除外(既に自軍駒のある位置には移動できない)」となるが、歩兵の場合は「駒の居る位置のbbを1つだけシフトして、自軍駒の位置を除外」で一気に移動先候補が求まり、そこから逆算で移動元を求める方が効率が良い。 縦型Bitboardならではの方法で、64bit整数2つを繋げてshiftするのは面倒だが正しい盤面であればそもそも境界の場所には歩は居ないはずなのでそこは無視してそれぞれ独立してshiftしてしまえば良い。 手番によって方向が変わるので分岐は必要になるが、いちいち移動元と移動先の2重ループを回す必要がなくなり簡潔になる。なるほど〜。

大量に生成するデータはシンプルに

駒移動で相手の駒を取る指し手の場合、それを局面に適用する (do_move) のときには必要ないが 直前の状態に戻す (undo_move) ときには「何の駒を取っていたか」の情報が必要なのでどこかに記録しておく必要がある。

最初、どうせ undo_move のときにしか使わない情報で そのときにはどの指し手を戻すのか指定するわけだから指し手内に取得駒情報を入れておけば良いよね、と思ってそうしていた。

// ........ ........ ........ .####### : to
// ........ ........ ........ #....... : promotion flag
// ........ ........ .####### ........ : from (0 if drop move)
// ........ ........ #....... ........ : drop flag
// ........ ...##### ........ ........ : moved piece or dropped piece type
// ...##### ........ ........ ........ : captured piece (if moved)
pub struct Move(u32);

しかしそうすると、合法手候補の生成のたびに移動先の駒の情報を取得して 24bit shift して格納することになり、これも一つ一つは軽いものの 積み重なると重いものになっていた。

結局ここはAperyの実装に倣って、局面の履歴である states フィールドに持たせるようにして 指し手を適用したときにだけ取得して格納する方式に。指し手 Move の上位8bitは使わない実装になった。

あとで一気にまとめて処理する

合法手のリストは Vec に格納すると たとえ with_capacity() で最大サイズを指定していても遅く、 arrayvec crate を使うことで改善することは確認していた。

それはそれで良かったが、非合法手を除外する処理でまたパフォーマンスが違ってきていた。

まず合法手生成は基本的に「動かせる駒を可能な範囲に動かす手を列挙」するが、その中には非合法な手になってしまう場合がある。

  • 玉将がみずから相手の駒の利きの範囲に動いてしまう
  • 飛び駒から玉を守っている駒がそこから外れてしまう

これらは王手放置の一種となる反則なので除外する必要がある。

www.shogi.or.jp

なので、極力余計な容量を使わないように、と考えて最初は arrayvec への push の前に判定するようにしていた。

use arrayvec::ArrayVec;

pub struct MoveList(ArrayVec<Move, { MoveList::MAX_LEGAL_MOVES }>);

impl MoveList {
    const MAX_LEGAL_MOVES: usize = 593;

...

    fn push(&mut self, m: Move, pos: &Position) {
        if pos.is_legal_move(m) {
            self.0.push(m);
        }
    }
}

しかし、実際にはApery(や、他のエンジンでも同様にやっている?)のように「一旦候補手は全部突っ込んでおいて、最後に一気になめて詰め直す」という方式にした方が速かった。

    pub fn generate_legals(&mut self, pos: &Position) {
        ...

        let (mut i, mut size) = (0, self.0.len());
        while i != size {
            if pos.is_legal_move(self.0[i]) {
                i += 1;
            } else {
                size -= 1;
                self.0.swap(i, size);
            }
        }
        self.0.truncate(size);
    }

    fn push(&mut self, m: Move) {
        self.0.push(m);
    }

やっていることとしては最後にループ回して順番に判定し、非合法手だった場合は末尾と交換して size を縮小していって、最後に合法判定された sizetruncate() する、というもの。

判定回数自体は変わらなくて理論上の効率は一緒なのだけど、多少ではあるが速度に違いが出た。

やねうら王のやねうらおさんによると「単純な処理に分割してループ回したほうが、レジスタ効率良くなって速くなる」とのこと…

もしかすると push に渡す引数が増えることによるオーバーヘッドなどもある…?

最適化によってどのように処理されるようになるのか、レジスタが実際どのように使用されて計算されるのか、など コードだけでは見えない部分まで考える必要があり非常に難しい……。

現状

上述のような幾つかの改善を重ねた結果、以前よりは格段に速くなり、手元のMacbookで試した限りでは Aperyと比較しても数%遅いかな?くらいで 2倍もの差がつくようなことは無くなったように思える。

むしろ nightlyで portable_simd を使うようにしたら さらに速くなって追い越すかもしれない。

まだ細かいチューニングはできるかもしれないし Zobrist hash など未実装の機能を追加することでまた遅くなったりもするかもしれない。 飛び利きの計算を別の実装に変えることでさらに改善もされるかもしれない。ようやくスタートラインに並べたので、そろそろ試してみたいところ。

とりあえずは当初の方針であった 「unsafeunwrap を極力つかわないRustらしい書き方」だけでこれくらいまで出来たことは嬉しい成果だ。

Rustでつくる もう一つの将棋ライブラリ

昨年末に出版された「強い将棋ソフトの創りかた」という本を読んで、自分も将棋AIを作ってみたいと思った。

この本では主にPythonでの実装が紹介されていたが、自分は最近はRustが好きなのでRustで自分で実装してみたい、と考えた。

最近では自作詰将棋SolverもRustで書いている。

memo.sugyan.com

局面探索、パフォーマンス

まず、局面の探索について考えた。

詰将棋Solverの場合も同様だが、将棋ソフトを作る場合にも、とにかく「今の局面からこの先どのような局面が発生し得るか」を高速に大量に探索していく必要がある。 現局面を根とするゲーム木を、合法手を辿って次々と子ノードに移ったり戻ってきたりして探索することになる。

これは、「各局面における合法手の列挙」と「指し手の適用(また、その巻き戻し)による局面状態更新」を繰り返すことで可能なので、これらを高速に計算できる必要がある。

Rustには shogi crateという優れた将棋ライブラリがあって、前記事の詰将棋Solverなどもそれを使って実装していた。

https://crates.io/crates/shogi

ただこのライブラリ自体には「合法手の列挙」を行うメソッドは用意されていないので、列挙するためには自前で用意する必要がある。 例えば以下のような感じで、指し手の候補を列挙できる。

use shogi::{Move, Piece, PieceType, Position, Square};

fn generate_move_candidates(pos: &mut Position) -> Vec<Move> {
    let mut ret = Vec::new();
    let color = pos.side_to_move();
    // まず自軍の駒が存在する位置を列挙
    for from in *pos.player_bb(color) {
        let from_in_promotion_zone = from.in_promotion_zone(color);
        // 指定位置に存在する駒を取得
        if let Some(p) = pos.piece_at(from) {
            let can_promote = p.piece_type.promote().is_some();
            // 指定位置から指定駒が動ける位置を列挙
            for to in pos.move_candidates(from, *p) {
                // 移動元と移動先から駒の成りが可能か否かを判定
                let promotions =
                    if can_promote && (from_in_promotion_zone || to.in_promotion_zone(color)) {
                        vec![false, true]
                    } else {
                        vec![false]
                    };
                for promote in promotions {
                    ret.push(Move::Normal { from, to, promote })
                }
            }
        }
    }
    // 持駒がある場合
    for piece_type in PieceType::iter() {
        if pos.hand(Piece { color, piece_type }) > 0 {
            // 空いているSquareを探す
            for to in Square::iter() {
                if pos.piece_at(to).is_none() {
                    ret.push(Move::Drop { to, piece_type });
                }
            }
        }
    }
    ret
}

これはあくまでも駒の動きの「候補」であり、ここからさらに以下のような非合法手を除外する必要がある。

  • 王手放置、もしくは自玉を相手駒の利きにさらす手
  • 歩・香車・桂馬の動かせない位置への不成や打
  • 二歩、打ち歩詰め
  • 千日手

これらは実際に pos.make_move(m) を呼んでみると MoveError が返ってくるので判定することができる。

(少なくとも現時点で) shogi ライブラリを使う場合はこのような感じで合法手を列挙していくことになるはず。詰将棋Solverを作るときもこういった方法で合法手を生成していた(詰将棋の場合は攻方のときに王手かける必要があるのでそのチェックも必要になるが)。

shogi ライブラリはBitboardを使った実装で非常に速いのだけど、それでもまったく問題ないわけではない。

perft

局面探索の確認と測定に使えるものとして、 perft というものがある。

ある局面から指定した深さまで局面を探索していくと全部で幾つの局面が現れるか、を数え上げる。「手順違いで同一局面に合流」などは考慮せず、あくまでゲーム木を再帰的に探索していく際のノード数を数える、という感じ。もともとはチェスの世界で使われていたものなのかな?

当然ながら深さを増やすと指数関数的に局面数が増えていくわけで、平手の初期局面からの局面数などは以下の記事などでまとめられている。

qiita.com

やねうら王Rust版Apery などでは、USI拡張コマンドとして go perft 5 などで実行できるようになっている。

$ cd ~/.ghq/github.com/HiraokaTakuya/apery_rust
$ cargo run --release                                                                           Compiling apery v2.0.0 (/Users/sugyan/.ghq/github.com/HiraokaTakuya/apery_rust)
    Finished release [optimized] target(s) in 44.26s
     Running `target/release/apery`
isready
readyok
go perft 5
1g1f : 821423
2g2f : 763797
3g3f : 777353
4g4f : 727359
5g5f : 728065
6g6f : 722473
7g7f : 1099961
8g8f : 721424
9g9f : 879050
1i1h : 623123
9i9h : 721423
3i3h : 455271
3i4h : 495883
7i6h : 604467
7i7h : 643428
2h1h : 713800
2h3h : 687521
2h4h : 650175
2h5h : 647634
2h6h : 638030
2h7h : 666383
4i3h : 454476
4i4h : 534086
4i5h : 529913
6i5h : 524756
6i6h : 606016
6i7h : 596975
5i4h : 567032
5i5h : 614526
5i6h : 645667

Time duration: 468.879021ms
Searched: 19861490 nodes : 42359519 nps
(Moved: 746131 nodes : 1591308 nps)

これは合法手の列挙さえできれば単純なDFSで実装できるので、 前述のような shogi ライブラリを使った列挙関数を使う場合、以下のように簡単に試すことができる。

use shogi::bitboard::Factory;
use shogi::{Move, Piece, PieceType, Position, Square};
use std::time::Instant;

fn main() {
    Factory::init();
    let mut pos = Position::new();
    pos.set_sfen("lnsgkgsnl/1r5b1/ppppppppp/9/9/9/PPPPPPPPP/1B5R1/LNSGKGSNL b - 1")
        .expect("failed to set sfen");

    let now = Instant::now();
    let total = perft(&mut pos, 5, true);
    let duration = now.elapsed();
    println!();
    println!("Time duration: {:?}", duration);
    println!(
        "Searched: {total} nodes: {} nps",
        (total as u128) * 1_000_000_000 / duration.as_nanos()
    );
}

fn generate_move_candidates(pos: &mut Position) -> Vec<Move> {
    ...
}

fn perft(pos: &mut Position, depth: usize, is_root: bool) -> u64 {
    let mut ret = 0;
    for m in generate_move_candidates(pos) {
        if pos.make_move(m).is_ok() {
            let count = if depth == 1 {
                1
            } else {
                perft(pos, depth - 1, false)
            };
            pos.unmake_move().expect("failed to unmake move");
            ret += count;
            if is_root {
                println!("{m}: {count}");
            }
        }
    }
    ret
}

で、手元のMacbookで実際にこれを動かして試してみると。 深さが増えるにつれて激重になる…。 同じ環境で実行してみてもRust版Aperyと比較すると圧倒的に遅い。深さ 5 だと1000倍近くかかっている。

perft depth nodes elapsed time (by shogi crate) elapsed time (apery_rust)
1 30 497.517µs 335.881µs
2 900 12.958808ms 880.983µs
3 25,470 330.535013ms 1.986988ms
4 719,731 9.00854434s 18.815674ms
5 19,861,490 241.722585518s 328.217788ms

これは主な理由として pos.make_move(m) 内で指し手の試行をして前述したような王手放置や反則手になっていないかという判定を繰り返しているところがあり、一回あたりの処理はさほど重くないものの探索の繰り返しの中で積み重なって遅くなってしまっていると思われる。

この設計自体はまったく悪くなくて、例えば「何らかのUIを用意して将棋ゲームを作る」といった際にはユーザが不正な指し手を入力してきた際にエラーを検出して表示したりする必要があるので、毎回の指し手ごとにそういった判定するのが正しいはず。

一方で将棋ソフト(思考エンジン)を作る際には「指定局面から合法手のみを辿って探索していく」という限られた用途になるので、「合法手のみを正しく列挙する方法があり」「そうして生成された合法手のみを受け取る前提で局面状態を更新する」という組み合わせで計算する仕組みになっていると余計なチェックを省くことができ、高速な探索を実現できる。

自作してみた

ということで、 shogi crate とは別の「もう一つの将棋ライブラリ」を自分で書いてみることにした。

github.com

Aperyはまさに高速な探索ができるよう実装されているRust製のソフトウェアで素晴らしい、と思ったものの、これは全体として思考ルーチンも含んだUSIエンジンとして一つの製品で、ライブラリとして使う感じではない。 shogi crate のようにシンプルな構造と機能だけを持つ高速な局面探索ライブラリが存在していると嬉しい、と思ったのでそういったものを自作することにした。

主にAperyのコードを参考に、合法手生成部分を抽出しつつ自分なりに理解しながら書いている。現時点では「爆速!」までは目指さず、Aperyよりは多少遅くても仕方ないと割り切りつつ出来る限り正しく型安全で unsafeunwrap を極力使わない実装にするようにしてみている。

まだ完全に実装できていない部分もあるが、 perft 6 までは正しい局面数が出るのを確認している。 現時点ではパフォーマンスはAperyと比較して 〜数十% ほど遅い、という感じで、perft 5 が600ms程度で2倍近く時間かかるが何百倍も遅いということはない、というところ。 これからまだ足りていない部分を実装したり パフォーマンス改善に取り組んでいくつもり。

Bitboardメモ

今まで「Bitboardってのを使うと駒の利きを高速に計算できるらしい」くらいの何となくしか知らなくて、今回はじめてBitboardまわりを自分で書いてようやく理解したので備忘録的にメモ。

基礎

Bitboardは盤面上の何らかの情報を持つ位置を表現するデータ構造。各マスに1bitを割り当てて、その位置に駒が存在していれば1、存在していなければ0、として全体の情報を幾つかの整数値で保持しておく。チェスやオセロなら64bitでちょうどよく収まるのだけど将棋は81マスなので、64bit整数を2つ使ったり工夫する必要があるようだ。Aperyでは1〜7筋、8〜9筋で2つの64bit整数に分けて、右上から下に見ていく所謂「縦型Bitboard」という方式だったのでそれを真似した。

    v[1]|                   v[0]
   9  8 |  7  6  5  4  3  2  1
 -------|-----------------------
| 09 00 | 54 45 36 27 18 09 00 | 一
| 10 01 | 55 46 37 28 19 10 01 | 二
| 11 02 | 56 47 38 29 20 11 02 | 三
| 12 03 | 57 48 39 30 21 12 03 | 四
| 13 04 | 58 49 40 31 22 13 04 | 五
| 14 05 | 59 50 41 32 23 14 05 | 六
| 15 06 | 60 51 42 33 24 15 06 | 七
| 16 07 | 61 52 43 34 25 16 07 | 八
| 17 08 | 62 53 44 35 26 17 08 | 九
 -------|-----------------------

例えば将棋の盤面を表す場合はまず「先手の駒の位置を表すBitboard(以下、bb)」「後手の駒の位置を表すbb」をそれぞれ持っておき、それらと別に「各駒種に対応する位置のbb」をそれぞれ持っておく。 例えば「先手の銀」の位置を知ろうと思ったら「先手のbb」「銀のbb」の論理積(&)をとれば対応する位置のbitだけが立っているものを得られる。

color_bb[Color::Black.index()] & piece_type_bb[PieceType::GI.index()]

000000000   001000100    000000000
000000000   000000000    000000000
000000000   000000000    000000000
000000000   000000000    000000000
000000000 & 000000000 => 000000000
000000000   000000000    000000000
111111111   000000000    000000000
010000010   000000000    000000000
111111111   001000100    001000100

また、後述の飛び駒の駒の利きを求めるために「先手後手問わず何らかの駒の置いてある位置のbb」も保持しておくと良い。

指し手を適用して駒が動いた場合はそれぞれ対応するbbの移動元・移動先の位置のbitだけ反転することで更新できる。

で、Bitboardの整数値からマスの位置を取る方法。

u64::trailing_zeros() で lowest set bit の位置を一発で得ることができる。 取得後は n &= n - 1 でそのbitを落とせるので、複数bitが立っているときは値が0になるまで取得と更新を繰り返せば良い。

let mut n: u64 = 0x4000_0000_0400_0000;
assert_eq!(26, n.trailing_zeros());  // 26 => Square::SQ39
n &= n - 1;
assert_eq!(62, n.trailing_zeros());  // 62 => Square::SQ79
n &= n - 1;
assert_eq!(0, n);

CPU命令と簡単なbit演算で求められるので、全81マスぶんループしてチェックするより遥かに速いわけだ。

利きテーブル

ここまでは「駒の位置の求め方」。 で、重要なのはここからの「駒の利きの求め方」。

1. 歩兵・桂馬・銀将金将・王将 の場合

先手の銀が3九に居ることが分かったとして、ではこの駒がどこに動けるか?

単純に考えると銀は正面と斜め4方向へ移動可能なので、現在位置から各方向に1つ動かした場所を列挙すれば良いが、九段目にいる場合はもう後ろには下がれない、など状況によって結果は異なる。

とはいえ「どちらの手番で」「どの位置にいる場合に」どこに動けるかは一意に定まるので (ちなみに移動先に既に駒がある場合も動かせないが、どこかで「自陣の駒のbb」の否定(反転)との論理積をとれば除外できるのでここでは考慮する必要ない)、予め移動可能な位置を計算して 手番数(2) x マス数(81) のBitboardを格納した「利きテーブル」を用意しておけば良い。

そうすれば、表引き一発で移動可能な位置を示すbbを得ることができる。

let attack_table: [[Bitboard; Square::NUM]; Color::NUM] = ...;  // 事前計算

let bb = attack_table[Color::Black.index()][Square::SQ39.index()];
for to in bb & !color_bb[Color::Black.index()] {
    ...
}
2. 香車・角行・飛車 の場合

他の駒にぶつからない限り端まで移動できるこれらの駒の場合は、進路にどのように駒が存在しているかによって結果が異なるのでそう簡単にはいかない。

とはいえ進路の中での駒の存在パターンはそこまで多くはないので頑張ってテーブルを作ることができる。 「どちらの手番で」「どの位置にいる場合に」の他に「先後問わず駒の置いてある位置のbb(occupied_bb)」の情報を使って表引きすることになる。

香車の場合は対象位置の筋の、縦のラインの状態だけわかれば他はどうでも良い。さらに上下両端は駒があってもなくても関係ないので、見るべきは二〜八の段の7箇所のみ、となる。7箇所だけなら駒の存在パターンは 2 ** 7128 通りしか存在しない。ので、手番数(2) x マス数(81) x 127パターン のテーブルを事前計算しておけば良い。縦型Bitboardであれば連続しているbit maskで得られたパターンの部分の値を一定量shiftするだけでindexが簡単に計算できる。

例えば初期局面から 1九香車が動ける位置を計算するときは以下のようなイメージ。

occupied:       mask:
111111111   .........    .........
010000010   ........#    ........0
111111111   ........#    ........1
000000000   ........#    ........0
000000000 & ........# => ........0
000000000   ........#    ........0
111111111   ........#    ........1
010000010   ........#    ........0
111111111   .........    .........
let attack_table: [[[Bitboard; 127]; Square::NUM]; Color::NUM] = ...;  // 事前計算

let mask = Bitboard::mask_from(File::FILE1);
let index = (occupied_bb & mask).value() >> 1;  // => 0b0100010
let bb = attack_table[Color::Black.index()][Square::SQ19.index()][index];

飛車・角行は、手番による違いはないものの縦にも横にも動くので香車のように単純にはいかない。 各マスから届き得るパターン数も多く、例えば5五の位置からはどちらの駒の場合も4方向あわせると12マスのパターンを考慮する必要があり、それだけで 4,096 通りが必要。

KA from SQ55   HI from SQ55
.........      .........
.#.....#.      ....#....
..#...#..      ....#....
...#.#...      ....#....
.........      .###.###.
...#.#...      ....#....
..#...#..      ....#....
.#.....#.      ....#....
.........      .........

それ以外のマスでも同様で、幾つかは変化するものの合計すると 角行は 20,224 、飛車は 495,616 パターンの考慮が必要になる。今どきのコンピュータならこれくらいのデータは保持しておくのは問題ないが、事前計算するのが少し時間かかったりする。

そして、事前計算していたとしても各マスからのbit maskに対応するindexを計算するのが難しく。 香車のときのように縦に並んでいるわけではないのでshiftでは実現できず、どうにかして各箇所のbit情報を集めてくる必要がある。

その1つの方法が、BMI2命令セットの PEXT のようだ。

X86 Bit manipulation instruction set - Wikipedia

これがまさに前述のような指定したmaskに該当するbitだけを集めてきてくれるもの、のようだ。

参考: RustからCPU拡張命令を使ってみる | κeenのHappy Hacκing Blog

shogi crateでは bitintr というcrateを使っていて、これはどうやら対応しているCPUアーキテクチャか否かで振り分けてフォールバック処理をしてくれるっぽい。

とはいえ対応するのは64bit整数なので、一発で求めるためには2つの整数の論理和(|)を取ってやることになる。ここは7列2列で分割しているとmaskのbitが重複しないので便利。

fn occupied_to_index(occupied: Bitboard, mask: Bitboard) -> usize {
    (occupied & mask).merge().pext(mask.merge()) as usize
}

ちなみにPEXTは一部のアーキテクチャでは圧倒的に遅いという問題があるらしく、使えるからといって必ず使うようにするのはあまり良くないのかもしれない。難しい…

PEXTを使わずにbit maskからindexを計算するもう1つの方法として、magic tableを使うものがあるようだ。これは試していないのでちゃんと理解していないけど、何らかの整数を乗算してshiftしてやったりすることでPEXTしたときと同様の結果を得られる、というものらしい。そのための整数値がまさにマジックナンバーの羅列になるので、こういう実装をMagic Bitboardと呼ばれている、のかな?

ともあれ、利きテーブルを利用する場合は事前計算が少し大変なので、どのタイミングでそれをやるかという問題も生じる。 shogi crateでは shogi::bitboard::Factory::init() という関数を用意していてユーザが明示的にそれを呼ぶことを強制している。 一方でAperyでは once_cell crateを利用して once_cell::sync::Lazy でテーブルを初期化していた。

参考: lazy_static はもう古い!? once_cell を使おう

初期化のタイミングは指定しづらいが、Factory::init()を呼び忘れるといったことは起きずに済むので良いかもしれない。

その他

また、こうした利きテーブルから表引きせずに求める方法もあるようで、最近ではQugiyの手法が話題になっている、のかな? まだ原理をちゃんと理解できていないが実装して試してみたいところ。

高速探索のためのテクニック

Aperyから移植しながら、「なるほど〜」と思った部分が幾つかあったのでメモ。 (実際どこまで速度に影響するかは環境などによっても変化すると思うしちゃんと効果を計測したわけではない)

とにかく数値として扱う

例えば駒の種類なんかは数種類程度なので enum を使うのが設計としては妥当だと思うのだけど、そうすると例えば成り駒への変換などが面倒になる。

enum PieceType {
    FU, KY, KE, GI, KA, HI, KI, OU,
    TO, NY, NK, NG, UM, RY,
}

fn promoted(pt: PieceType) -> PieceType {
    match pt {
        PieceType::FU => PieceType::TO,
        PieceType::KY => PieceType::NY,
        PieceType::KE => PieceType::NK,
        PieceType::GI => PieceType::NG,
        PieceType::KA => PieceType::UM,
        PieceType::HI => PieceType::RY,
        pt => pt,
    }
}

一見きれいに書けてスッキリしているようだけど、 match 式がどれくらい効率的に動いてくれるかあやしい。実際には比較と分岐が多くなると思われる。

なので、型に数値を持たせるようにして、ある桁にflagを持たせるようにする。

struct PieceType(u8);

const FU: PieceType = PieceType(1);
const KY: PieceType = PieceType(2);
...
const TO: PieceType = PieceType(9);
const NY: PieceType = PieceType(10);

fn promoted(pt: PieceType) -> PieceType {
    if pt.is_promotable() {
        PieceType(pt.0 | (1 << 3))
    } else {
        pt
    }
}

と、内部の数値に論理和してやるだけで済むようになる。この場合はうっかり金将や王将が成ってしまわないよう判定が必要になったりするが…。

指し手を表現する Move なども enum などを使わず 32bit 整数の中に移動元、移動先、駒種などの情報を含めるようにしている。このへんは特に配列として格納したりするのもあってアラインメントにも関わってくるのかな?

とにかく無駄な計算をしない

例えば Square::SQ39 = Square(26) というマスの位置を示すものがあって このマスの筋と段を求めたいとき、縦型のレイアウトなら

fn file_rank(sq: Square) -> (u8, u8) {
    let file = sq.0 / 9 + 1;
    let rank = sq.0 % 9 + 1;
    (file, rank)
}

と除算と剰余で求めることができる。 が、これも無駄な計算であって表引きできるものなので、各マスから筋と段を求めるためのテーブルを用意してそこから引くようにしてやる。

const SQUARE_TO_FILE: [u8; Square::NUM] = [1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, ...];
const SQUARE_TO_RANK: [u8; Square::NUM] = [1, 2, 3, 4, 5, 6, 7, 8, 9, 1, 2, 3, ...];

fn file_rank(sq: Square) -> (u8, u8) {
    (SQUARE_TO_FILE[sq.index()], SQUARE_TO_RANK[sq.index()])
}

とにかく事前に計算できるものは先に用意しておいて極力計算回数を減らす、という工夫が大事なようだ。

SIMD

Rust版Aperyは昨年末くらいに64bit整数2つの計算をSIMDを使用して行うよう変更していたようだ。

https://github.com/HiraokaTakuya/apery_rust/commit/465b5c6991716062ef16b704bb112513e6381435

これもアーキテクチャによって処理を分岐したりする必要があって少し面倒そうではあるが、nightlyで使えるportable_simdというfeatureがあるようだった。

https://github.com/rust-lang/portable-simd

一応これを使って入れ替えても動くことは確認できて、体感少し早くなったような気はするけど誤差の範囲内かもしれない。

Use `portable_simd` feature by sugyan · Pull Request #1 · sugyan/yasai · GitHub

まとめ

結局ライブラリ作りに一生懸命になってしまって、将棋ソフト作りにはまったく取り組めていない…。