♠️

【WebAssembly】ブラウザ上で動作するポーカーソルバーを開発した話

2022/06/19に公開
8

初投稿です。数あるポーカーの変種の中で世界的に最もポピュラーなものと言ってよいテキサスホールデムにおける、数学的に最適な戦略を解くソルバーを趣味で開発したので、その技術的な部分を備忘録を兼ねて投稿してみます。非常にニッチなアプリケーションではありますが、刺さる方には刺さるんじゃないかなと期待しています。

開発したものはWebアプリケーションとなっているので、以下のリンクから試すことができます (モバイル端末向けにはデザインされていないのでご注意ください):
https://wasm-postflop.pages.dev/

GitHubのレポジトリはこちらです:
https://github.com/b-inary/wasm-postflop

そもそもポーカーのソルバーとは何ぞや

さて、いきなり「テキサスホールデムのソルバーを作りました」と言われても多くの方にはピンと来ないと思いますので、そもそもテキサスホールデムとはどういうゲームなのか、またそのソルバーとは何なのかについてまずは軽く紹介することにしましょう。

テキサスホールデムのルール

まずテキサスホールデムについてですが、カジノでポーカーと言ったらほとんどの場合このテキサスホールデムを指すくらいには世界中にプレイヤーが数多く存在するゲームです。その規模の大きさは、年にもよりますが最も権威のある世界大会では1000万ドル (!) もの優勝賞金が懸けられたりするほどです。日本ではまだ馴染みの薄い感じもありますが、少しずつプレイヤーも増えてきているように思います。

ここではテキサスホールデムのルールを大ざっぱにだけ紹介することにしましょう。まず、各プレイヤーには手札となるホールカードが2枚ずつ配られます。このホールカードは、最後に強さを競うことになるまで他のプレイヤーから隠しておきます。よくあるクローズドポーカーとは異なり、配られたホールカードを交換したりすることはできません。また、プレイヤー全員が共通して使用できるコミュニティカードが場に5枚公開されるので、これら7枚の中で最も強い5枚を用いて役の強さを競います。役の強さを競うことをショーダウンと呼びますが、このショーダウンが行われた場合は最も強い役を作ったプレイヤーがそれまでに賭けられてきたポットを総取りします。

テキサスホールデムにはプリフロップフロップターンリバーという4つのベットラウンドがあり、それぞれホールカードが配られた時点、コミュニティカードが3枚公開された時点、コミュニティカードの4枚目が公開された時点、コミュニティカードがすべて公開された時点から開始されます。各ベットラウンドでは、賭け金の合意が取れるまで各プレイヤーは定められた順序で以下のいずれかのアクションを取る必要があります:

  • ベット / レイズ: 現在の賭け金からさらに賭け金を上乗せするアクションです。賭け金がゼロの状態から行うアクションをベット、すでにベットされている状態から行うアクションをレイズと呼びます。
  • チェック / コール: 現在の賭け金に同意し、同額を支払ってプレイを継続するアクションです。追加の賭け金が必要ない状態で行うアクションをチェック、賭け金を追加で支払う必要のある状態で行うアクションをコールと呼びます。
  • フォールド: 現在の賭け金を支払わず、手札を捨てて勝負から降りるアクションです。フォールドを行うとそれ以降のベットラウンドにも参加できず、ポットを獲得する権利を完全に失います。

1人を除いたプレイヤーがすべてフォールドした場合、ショーダウンは行わずに残った1人がポットを獲得します。

テキサスホールデムのソルバー

さて、以上がテキサスホールデムのルールの大ざっぱな紹介でしたが、ではそのテキサスホールデムのソルバーとは何なのかの説明に移りましょう。ソルバーとは、一言で言ってしまうと、各ベットラウンドにおける各プレイヤーの最適なアクション (より正確には最適な戦略) を弾き出してくれるプログラムのことです。

ここで、簡単に「最適なアクション」などと言いましたが、この最適なアクションを実際に計算するには次の2種類の困難に立ち向かわなければなりません: そもそも「最適」とは何かという定義と、定義ができたとしてそれが現実的な計算資源で計算可能なのかという点です。

まず1点目の「最適」の定義についてですが、ここでは天下り的にナッシュ均衡を「最適」の定義とすることがほとんどであると述べる程度にしておきます。また、2点目の現実的にどこまで計算可能なのかについては、counterfactual regret minimization (CFR) と呼ばれるアルゴリズムを用いることで、それなりに複雑なゲームであってもナッシュ均衡の十分な近似解を得ることができます。なお宣伝になりますが、ナッシュ均衡やCFRアルゴリズムに関するより詳しい解説記事に興味があるという方がいましたら、私が所属している同人誌サークル yabaitech.tokyo の vol.7 の購入をご検討ください。

閑話休題、将棋や囲碁などのゲームの完全解析が現実的には不可能であるように、テキサスホールデムも完全解析を行うにはゲームが複雑すぎます。そこで、ベットやレイズの額の候補を数種類に限定した上で、コミュニティカードが3枚公開されたフロップ以降の解析を行うプログラムを、この記事では「テキサスホールデムのソルバー」と呼ぶことにします。なお、ここまで条件を限定しても実際の計算には数分から場合によって数時間を要します。

既存のソルバーソフトウェア

先述したように世界大会ではとんでもない額の優勝賞金が懸けられていたりするわけですから、テキサスホールデムの解析が行えるソフトウェアの開発はビジネスになります。実際、テキサスホールデムのソルバーと言えば大体以下のような商用ソフトウェアを指します:

3番目に挙げた GTO+ が $75 なのが安いくらいで、基本的にポーカーというのは勉強に結構なお金が掛かるゲームなのです。憧れているトッププレイヤーがソルバーを使っているので自分も使ってみたい! と思っても、では最低でも1万円からになりますと言われるとちょっと躊躇してしまう方も多いのではないでしょうか。というか自分がそうでした。

もっと気軽に多くのプレイヤーがソルバーを試せたら嬉しいよね

ということで、ここまで来ると私の開発のモチベーションを共有することができるのではないかと思うのですが、見出しにあるようにソルバーをもっと気軽に試せるものにしたいと思ったというのが、今回私が趣味でソルバーを開発し始めた最も大きな理由です。ですから、開発したソルバーは無料で公開するというのは前提として、どうしたら「気軽に」ソルバーを使ってもらえるかも検討した結果、インストール不要のブラウザ上で動作するアプリケーションにすることが技術的に可能らしいことが分かったので、その方向性で開発を進めることにしました。

ところで無料のソルバーは今まで本当に無かったの?

TexasSolver というのがあるにはあります。GitHub では Star も 600 以上ついていて良さそうに見えますね。ただ実際に使ってみるとたまにクラッシュしたり、全員が必ずチェックするように設定したときの EV (利得の期待値) は一意に定まるはずなのに手元の GTO+ の計算結果からズレていたりと、なんかいまいち信用できませんでした。まあ前者のクラッシュについては目をつぶるにしても、後者の計算結果が合わないというのは基本的なテストを怠っている証拠であり、ソルバーとして致命的です。個人的にはこれを実用的なソルバーソフトウェアとしてカウントするのは難しいです。

技術選定

ここまでテキサスホールデムとそのソルバーについての説明がだいぶ長くなってしまいましたが、この記事は一応技術記事ですからここからが本題です。開発を始めるにあたって、まずは用いる技術を選定していかなければなりませんが、今回開発したいソルバーの特徴は次のようなものです:

  • 計算に非常に長い時間が掛かりうるもので、非常に高いパフォーマンスが要求される
  • ユーザーに気軽に使ってもらえるものにするため、ブラウザ上で動作させる

これら2つの要件を満たせる技術といえば、そう、WebAssembly (Wasm) ですね。というより、むしろ Wasm が存在するのでこれら2つの特徴を要件としたという方が正確でしょう。ブラウザ上で動作させるというのはそのままの意味で、ソルバーの実行をサーバーサイドで行って計算結果をユーザーに送信するとかいうことではなく、あくまでソルバーの実行を行うのはクライアントサイドです。そのためソルバーアプリは静的サイトとして公開を行うことができます。

最終的にプロジェクトで使用した言語・ライブラリなどをまとめると次のようになります:

以下、それぞれについて簡単に見ていくことにしましょう。

バンドラ: webpack

まずはバンドラからですが、マルチスレッドで動作する Wasm をバンドルするとかいうニッチな開発をすることになるわけですから、ここは素直に枯れた技術と言える webpack を採用することにしました。できれば使ってみたかった Vite は技術選定時は Web Worker の扱いに難があったりしてやむなく却下したのですが、記事を書くにあたって調べ直してみたら今はどうやら修正されていそうな感じですね。とはいえ今から Vite に移行するかというとそれは微妙なところです。

ソルバーエンジン: Rust → WebAssembly

WebAssembly にコンパイルすることが分かっているなら、やはり開発に一番適した言語は Rust でしょう。実際 Wasm の開発においては Rust が一番利用されているようですし、自分でちゃんと確認したことは無いのですが実行ファイルサイズの面からも Rust は有利なはずです。Wasm を抜きにしても、Rust の良さを知ってしまった今となっては C++ で新規の開発を始める気は起きないですね。今回はパフォーマンスが非常に重要という要件なので、現実的なところでは Rust と C++ 以外に選択肢は無いと思います。

wasm周り: wasm-pack, wasm-bindgen

wasm-bindgen については Rust での Wasm 開発において説明不要の実質必須ライブラリですし、バンドラを用いる場合は wasm-pack も順当に選ばれるライブラリでしょう。このあたりの説明は省きます。

マルチスレッド化: rayon, wasm-bindgen-rayon

パフォーマンスが非常に重要となる以上、マルチスレッドに対応しないことは考えられませんから、今回の開発において肝となる部分です。まずは一旦 Wasm のことは忘れることにすると、Rust でマルチスレッド処理を実現するには rayon ライブラリを使うのが非常にオススメです。今まで Iterator を使っていた部分を ParallelIterator に置き換えるだけで処理のマルチスレッド化が可能で、work stealing と呼ばれるアルゴリズムによって動的にスレッド間の負荷の分散も行ってくれます。今回のソルバーではゲーム木を走査する再帰関数がメインルーチンになりますから、work stealing との相性はバッチリです。

さて問題になるのは、どのように Wasm をマルチスレッドで動作させるかです。まだ提案段階ではあるものの、共有メモリとアトミックな命令は Wasm の仕様が定められていて各種ブラウザにおいて利用可能ですが、スレッドの生成については Wasm の仕様では何も定められていません。つまり、Rust 側では std::sync::atomicstd::sync::Mutex などは使えますが、std::thread は使えないということになります。

それではどうやってマルチスレッド化するのかというと、ブラウザ上では Web Worker が利用できるので、std::thread の代わりに JavaScript で Web Worker を制御するプログラムを書いて、それを Rust 側から呼び出すことでマルチスレッド処理を実現します。この処理を rayon ライブラリに組み込んでくれるのが wasm-bindgen-rayon ライブラリです。rayon そのものの使いやすさを抜きにしても、現状ではマルチスレッドな Wasm を Rust から生成する場合はこの wasm-bindgen-rayon を利用するのが最も安定しているのではないかと思います。

フロントエンド: TypeScript

フロントエンドについてはしょせん趣味の域を出ない程度の知識しかないので、素人なりに技術を選定したらこうなったという内容になります。素人だからこその視点というのも誰かの役に立つことがあれば良いなあと思いながら記事を書くことにします。

まずは共通して用いた言語についてですが TypeScript を採用しています。TypeScript による本格的な開発の経験は初めてだったのですが、VSCode で補完が強力に効いて、型の合わない処理を書くとエラーを吐いてくれるというただそれだけで非常に快適に開発を進められました。やはり型があることで開発体験の質は大幅に向上しますね。

あとは静的解析ツールに ESLint、コード整形に Prettier の両定番ツールも導入しています。このあたりの導入方法をググると毎年やり方が変わっている気がするのですが、その辺なんとかならんのかなという気持ちになります。

JSフレームワーク: Vue 3 (Composition API)

React と比較して思うこと

JSフレームワークには Vue 3 を採用しました。もともと Vue は若干の趣味経験があったので、今回は勉強を兼ねて React で作ってみようかなと当初は思っていたのですが、プロトタイプを作って比較してみたところ私は Vue の方が好きなことが分かり、React で作る計画は無かったことになりました。

Vue と React の比較についてはいろいろなところでいろいろなことが言われていますが、React 公式ドキュメントの「JSXを使う理由」には次のような一文があります。

マークアップとロジックを別々のファイルに書いて人為的に技術を分離するのではなく、React はマークアップとロジックを両方含む疎結合の「コンポーネント」という単位を用いて関心を分離します。

この一文を見ると、React ではその設計思想からして技術の分離と関心の分離が両立しないものとして考えられているように思えます。ただ、個人的な感想ではありますが、私にはこの技術が分離されていないという部分がスムーズな開発を進める上でデメリットにしか感じられませんでした。Vue はコンポーネントに切り分ける思想は取り入れつつも、そのコンポーネント内では <template><script><style> と技術が分離されています。こちらの方がプロトタイピングも楽でしたし、自分は今マークアップを書いているのか、ロジックを書いているのかが明確になるので開発にあたって気持ちも切り替えやすく、効率良く作業を進められました。

React はシェアの非常に大きいライブラリですから、このようなデメリットを受けないようなプラクティスがもちろんあるのだとは思います。またあなたの開発の規模が小さいから Vue で大丈夫だったんでしょという指摘もあるかもしれませんが、趣味でちょっと触る程度であれば私には Vue の方が向いていた、という話です。あとは React はちょっと条件付きレンダリングをしようと思うと && や三項演算子を使って記述することになるのがキモかったです。キモいと思うくらいなら小さい処理であっても変数に切り分けろということなのかもしれませんが、それだと上から下に流れる記述ではなくなってしまうのでそれはまた違うんですよね。

Vue 3 の開発体験

さて採用しなかった React の話が思ったより長くなってしまいましたが、Vue 3 (Composition API) + TypeScript の開発体験は快適でした。Vue は TypeScript との相性が悪いと言われていた時代も長かったように思いますが、今となってはそんなことはまったくありませんね。

状態管理ライブラリ: Pinia

今回初めて状態管理ライブラリなるものを導入してみたのですが、これただのリアクティブなグローバル変数ですね。便利だけど使うのには責任が伴う、ってやつです。最初は Vuex を使っていて、記述がだいぶ冗長になるなあと思っていたところに今は Pinia がホットらしいというのを見かけたのでこちらに乗り換えた次第です。こちらの方が記述が圧倒的にスッキリして好みですが、責任が伴う状態の変更が簡単に書けてしまうというのもどうなんだという気もします。今回くらいの規模感の開発においてはちょうど良い便利さです。

CSSフレームワーク: Tailwind CSS

良さげにデザインされた出来合いのコンポーネントを使っていると細かい不満が溜まっていってだんだん嫌になる体質なので、ユーティリティしか提供しない Tailwind CSS が私には向いています。デザインに関するプラクティスについては本当に知識がなく、雰囲気でやっているのであまり書けることがありません。

これは使わなくても何とかなるレベルのライブラリですが、Web Worker を使うなら Comlink を使うとそれなりに幸せになれます。

IndexedDB ラッパー: Dexie.js

ハンドレンジおよびゲーム木の設定をブラウザ内に保存できる機能を提供するため IndexedDB を利用しているのですが、その API をラップしてくれているのが Dexie.js です。IndexedDB の複雑さをいい感じに隠蔽してくれており、ドキュメントも充実していて非常に良いライブラリだと思いました。

ホスティングサービス: Cloudflare Pages

ソルバーアプリの公開先をどこにするか検討するにあたり、条件としたのは以下の2点です:

  • HTTPヘッダが編集可能であること
  • できれば無料のサービスであること

1点目のHTTPヘッダの条件については、マルチスレッド処理で必要になるブラウザの共有メモリ機能を有効にするために要求されるものです。共有メモリ機能は Spectre 脆弱性によって攻撃を受けうるため、他の origin から切り離された cross-origin isolated な環境であることをHTTPヘッダによってブラウザに通知しないと機能が有効にならないようになっています。当初は GitHub Pages が手軽で良さそうに思っていたのですが、この条件を満たせないために却下となりました。

あとは無料枠の充実度合いでサービスを比較した結果、後発の Cloudflare Pages が最も良さそうだったのでこれを採用することにしました。無料でも帯域幅が無制限で、商用利用も可能なので将来広告が貼りたくなっても大丈夫です。Wrangler という CLI ツールもあり、私はデプロイを自動化せずに手元でビルドしたものを手動でアップロードして利用しています。

WebAssembly って実際どうなのよ

ここからは、実際にソルバーを作ってみた結果 WebAssembly のパフォーマンスはどうだったのかについて書いていこうと思います。

実行ファイルサイズ

Webアプリとして公開する以上、Wasm の実行ファイルサイズは小さいに越したことはありません。今回作ったソルバーではどうなったかというと、複雑な計算を行うマルチスレッドプログラムであるにも関わらず 280KB (80KB gzipped) 程度に収まってくれました。依存するライブラリを極力減らして開発するよう心掛けはしましたが、個人的に想定していた以上にサイズが縮んで正直驚きました。

マルチスレッド化をしない版の実行ファイルサイズは 140KB 程度だったので、マルチスレッドライブラリがほぼ半分の容量を占めていることになります。ただ work stealing 込みのマルチスレッドライブラリが 140KB 程度で使えるというのは rayon すごいなあという気持ちです。

ここで、調子に乗って依存ライブラリを増やすとものによってはファイルサイズが大幅に増えてしまうので注意は必要です。例えば正規表現ライブラリ (regex) を使うと、私の環境では 700KB 程度の容量増になってしまいました。正規表現を用いた処理が必要な場合は、可能であれば JavaScript 側で処理を行うようにして、Rust 側には正規表現ライブラリを持ち込まないようにするなどといった工夫が求められます。

実行時間

WebAssembly について「ネイティブ水準の速度が出せる」と紹介されていることも少なくありませんが、実際のところはどうなのでしょうか。今回作ったソルバーに特定のインスタンスを入力として与えたものをベンチマークとして、Windows 10 上でネイティブアプリと比較してみた結果が次の通りです。なお WebAssembly 版は Google Chrome 102 上で実行しています (Firefoxだと大体1.1倍くらい遅いです) 。

追記 (2022-07-01): 以前は WSL2 上でのネイティブアプリと比較していましたが、Windows 上で直接実行できるネイティブアプリと比較するよう更新しました。

Ryzen 7 3700X (8コア16スレッド)

スレッド数 1 8 16
ネイティブ 161.2 s
(-)
25.8 s
(x6.25)
19.7 s
(x8.18)
WebAssembly
(Chrome)
340.2 s
(-)
53.0 s
(x6.42)
39.5 s
(x8.61)
実行時間比 +111.0% +105.4% +100.5%

Ryzen 9 5950X (16コア32スレッド)

スレッド数 1 8 16 32
ネイティブ 133.3 s
(-)
19.0 s
(x7.02)
13.1 s
(x10.2)
12.4 s
(x10.8)
WebAssembly
(Chrome)
246.9 s
(-)
34.1 s
(x7.24)
20.8 s
(x11.9)
16.5 s
(x15.0)
実行時間比 +85.2% +79.5% +58.8% +33.1%

条件によって差がつく結果となりましたが、さすがに多少なりともオーバーヘッドは存在して、ネイティブと同等の速度にはならないという結果となりました。とはいえ実行時間は2倍前後には収まっており、ブラウザ内で実行できるというメリットを考えれば十分な速度であるとも思います。マルチスレッド効率に関しては Wasm はネイティブよりむしろ効率的という結果になり、個人的には意外でした。特に Ryzen 9 5950X の32スレッドでのオーバーヘッドはわずか33%という数字になっています。

マルチスレッドで malloc が遅い問題

しかし! このように高いマルチスレッド効率を Wasm で実現するには乗り越えなくてはならない壁が存在します。それは、Rust の Wasm ターゲットでデフォルトで提供される malloc はマルチスレッド環境で効率的に動作するようには作られていないという問題です。

メモリ割り当ての話題は奥が深いのであまり深掘りしないように気をつけますが、WebAssembly は線形メモリ (linear memory) というモデルを採用しており、提供されているシステムコールのようなものは memory.grow() という関数ただ一つのみです。この memory.grow() は、プログラムが現在確保しているメモリ領域の末尾を拡張するもので、一度この memory.grow() によって確保された領域をブラウザなどに返すことはできません。このように非常な単純なメモリモデルが Wasm のシンプルさを支えているとも言えますが、仮想アドレス空間のような仕組みはまったく無いわけです。

そのため、mmap() を多用するような現代的なメモリアロケータをそのまま Wasm の世界に適用することは不可能で、Rust の Wasm ターゲットでは dlmalloc というアロケータを Rust に移植したものが標準で提供されています。ここで問題は、この dlmalloc はマルチスレッド環境で効率的に動作するようには作られておらず、複数のスレッドから同時に呼ばれた場合は1つのスレッドを除いてブロックされてしまう実装になっていることです。

この dlmalloc を普通に利用した Wasm 版ソルバーを実行してみると、6スレッドくらいまではスレッド数を増やすにつれて実行時間が短くなりますが、それより先はスレッド数を増やすと逆に実行時間が長くなってしまうという結果になりました。というより最初は malloc がボトルネックになるとは想像していませんでしたから、Wasm そのものの限界で6スレッド程度までしか有効活用できないのかと思っていました。

この問題をどのように解決したかというと、今回のソルバーではゲーム木を走査する再帰関数がメインルーチンであり、malloc は再帰関数内のローカル変数の割り当てにのみ用いられていることを利用しました。つまり最も最近確保されたメモリから順に解放されていくということですから、単純なスタックによってメモリを管理することが可能です。ということで、スレッド毎に最初にそれなりの領域を通常の malloc で確保してしまってから、その領域をスタックとして管理するアロケータを通してメインルーチンはメモリを確保・解放するようにすることで、通常の malloc がほとんど呼ばれないような実装を施しました。

今回はメモリの確保・解放パターンが特殊だったために単純な解決策を取ることができましたが、もっと複雑にメモリを確保・解放するような場合は汎用アロケータが必要になってしまうでしょう。Wasm ターゲットでマルチスレッド処理を見据えた汎用アロケータを開発しようという人が今後出てくるのかは疑問ですし、そのような場合は Wasm は適していないと判断した方が良いかもしれません。

踏み抜いたバグたち

ここからは、開発をしている際に踏み抜いたバグたちをいくつか紹介していきます。

Safari: Web Worker の内部で Web Worker が生成できない

Safari では Web Worker の内部で Web Worker を生成することができません (Bugzilla) 。Bugzilla で最初に報告されたのが2008年ですから、13年以上もの間このバグが放置されていることになります。対応が非常に大変なのであろうことは理解しますが、2022年になっても対応していないというのはどうなのでしょうか。

今回利用している wasm-bindgen-rayon ライブラリでは、メインスレッドはボスとなる Worker を生成し、このボス Worker が子分となる Worker をスレッド数の分だけ呼び出すようなモデルを使えと言っています。しかし Safari ではボス Worker が子分 Worker を呼び出せないということになりますから、このモデルが動作しません。技術的にはメインスレッドがスレッド数の分の Worker を直接管理することも可能なのかもしれませんが、wasm-bindgen-rayon を捨てるか、Safari を捨てるかの選択で私は Safari の方を捨てることにしました。そもそも wasm-bindgen-rayon の方を捨てたとして Safari が救えるのかどうか自信がありません。

一応、Safari を捨てたといっても Safari ではボス Worker が子分を呼ばずに自らが計算を行うようにすることで、シングルスレッドでは動作するよう本当に最低限の対応は行っています。とはいえソルバーがマルチスレッドで動かないというのは致命的ですから、macOS ユーザーの方は Chrome か Firefox を使ってくださいとしか言えません。iOS ユーザーの方はもうどうしようもないですが、モバイル端末の計算能力はたかが知れていますしサポート対象外とさせていただくことにします。

Firefox: 有効な Web Worker が GC に回収されてしまう

Firefox には Firefox で、有効な Web Worker を数秒放置すると GC に回収されてしまうことがあるというバグがあります (Bugzilla) 。こちらは workaround として有効な Web Worker を明示的にグローバル変数などに保存しておけば対処は可能で、実際 wasm-bindgen-rayon はそのように対応しています。

さて問題はここからで、せっかく Worker をグローバル変数に保存するよう対応しているのに、それらは未使用な変数だからとバンドラが最適化で削除してしまうことがありました。具体的には、一番最初のプロトタイプで採用していた Next.js がそうでした。恐らく最適化オプションのどこかをいじれば防げるのでしょうが、その原因を特定する気力も技術力も無く、これが原因で Next.js はお蔵入りになりました。まあ結局は先述したように React すら使わないことになったのですが。

Chrome: 仮想スクロールを実装した要素でスクロールイベントが発火しない

計算結果画面には各ハンドのエクイティや EV、各アクションを選択するべき確率といった詳細を表示するテーブルがあるのですが、このテーブルは最大で1000行を超えることになり、描画に掛かる時間が馬鹿にならないため仮想スクロールを自前で実装しています。ちなみに仮想スクロールとは、巨大なリストなどにおいて現在見えている要素とその周辺の要素のみをレンダリングし、見えていない要素は極力レンダリングを行わないようにすることで、描画のパフォーマンスの改善を図るテクニックのことです。なお、なぜ既存のライブラリを使わず自前で実装したのかというと、単に <table> 要素で使える良さげなライブラリが見つからなかったからです。

この仮想スクロールを実装した要素においてマウスのホイールを連続して回してスクロールしていると、Chrome では途中からスクロールイベントがなぜかまったく発火しなくなるというバグを踏みました。当然スクロールそのものも行われなくなり、ホイールをいくら上下に回してもうんともすんとも言わない状態となります。マウスカーソルを少しでも動かすか、1秒ほどホイールを回さずに待機すると再びスクロールできるようになるのですが、これでは地味に結構不便です。

これに関しては今でも原因がまったく分かっていないのですが、paint complexity なるものが関わっているのではというコメントを見つけ、overflow-y: scroll; を指定している <div> 要素に will-change: transform; という CSS プロパティを設定してみたところ、なんと症状が解決しました。完全に魔術です。なお自分の環境ではこれで解決したのでヨシ! ということでコミットしてしまいましたが、残念ながらデベロッパーツールを開くと症状が復活するんですよね。この件に関してなにか情報をお持ちの方がいらっしゃいましたらコメントいただけると大変助かります。

追記 (2022-07-01): こちらのバグを踏んでいたようです。現在は無事解消しています。

wasm-bindgen: メモリ使用量が 2GB を超えると想定外のアドレスを参照する

このバグに関してはいかにもありそうな感じのバグですよね。WebAssembly はアドレス空間が32ビットなので 4GB までのメモリを扱えるのですが、wasm-pack (wasm-bindgen-cli) が出力する JavaScript のグルーコードの中にアドレスを符号付き32ビット整数として取得している処理があり、そんなことをすると当然 2GB を超えるアドレスは負になってしまってバグります。

一旦アドレスが負になってしまっても、それを渡された側が再び符号無し32ビット整数として解釈してくれればバグにはならないのですが、JavaScript の TypedArray 型の subarray() は負のインデックスを渡されると配列の末尾からの位置として解釈して特にエラーも返さないので、単にバグった位置のデータが普通に返ってしまいます。

Wasm でメモリ使用量が 2GB を超えるアプリケーションなんてものは相当珍しいのでしょう。私は出力されたグルーコードを適当に sed で置換して対応していますが、可能なら大元のバグが直接修正される方が望ましいですから、contribution が好きな方がいればぜひ issue でも立ててきてくださると救われる人がいるかもしれません。私としては sed で対応できているのでアクションを起こすモチベーションがあまりありません。

追記 (2022-06-20): 結局 issue を自分で立てました。

終わりに

記事を振り返ってみると長文の割に内容が散り散りでターゲットがどこなのか謎な記事になってしまいましたが、備忘録を兼ねた記事ということでご容赦ください。読者の方に少しでも刺さる内容がもしあれば幸いです。

熱しやすく冷めやすい性格なので、商用ソルバーたちと比べるとまだまだ機能は全然足りていませんが、ひとまず動くものが完成してしまったので正直なところすでに少しずつ冷め始めてきています。ということで冷めきらないうちに記事を書いたという側面もあるのですが、この趣味開発を続けるとしたら今後は何を目標にするのが良さそうでしょうか。商用ソルバーに少しでも近づけるように機能を追加していくのも良いですが、つい先日 GUI フレームワークの Tauri が v1.0 に到達したということで、Wasm 版の資産をもとにネイティブ GUI 版の開発に取り掛かるのも面白いかもしれませんね。ただどちらにせよそれなりに気力は必要になりそうなので、どれくらい需要があるのか次第なところではあります。

追記 (2022-07-01): Tauri を利用した Windows 向けのネイティブ GUI アプリを公開しました。Tauri ですが、まだあまり安定していない感はあるものの Web アプリを驚くほど簡単にネイティブアプリに移植することが可能で今回のような用途には最適でした:
https://github.com/b-inary/desktop-postflop

ということで長かった記事もここまでです。ニッチな内容が多くなかなかヘビーだったのではと思いますが、読んでくださった方はありがとうございました。

Discussion

キッドキッド

面白かったです!レンジ設定なのですが、%をいじっただけでは、レンジが設定できませんでした。
自分でAA.KKなどを押さないとダメですか?

b_inaryb_inary

言われてみると確かに紛らわしいUIですね…。こちらとしては、13×13 のテーブルの各マスをクリック (ドラッグで複数マスの選択も可能です) することでレンジを設定していただくことを想定しています。Weight の数値はあくまで「クリックして選択したマスに適用する」重みの設定になります。
PioSOLVER や GTO+ のように、スライダーで上位X%のレンジを選択できるようにするにはハンドのランキングが必要となりますが、このランキングが自明ではないため現状では採用できていません。良い解決策が無いか少し考えてみます。
追記: PioSOLVER に保存されているレンジを使いたい場合は、「Clear」ボタンの左側のテキスト欄にペーストしていただくことでもレンジを適用できます。

AlmondAlmond

技術面について詳しく書かれていてとても勉強になりました!
ちょうど自分もRustでポーカーのソルバーを作ろうと思っていたところだったのですが、同じことを考えている人がいるとは思ってもいなかったです(笑)

b_inaryb_inary

まさか同じことを考えている方がいたとは私もびっくりです (しかも日本で!) 。今回の記事は Wasm 周りの技術的内容が主体になってしまいましたが、Rust 製のソルバーエンジンに興味があるという場合は https://github.com/b-inary/postflop-solver もご参照ください (すでにご覧になられているかもしれませんが) 。
商用ソルバーに近づこうと思うと課題は山積みです。ネイティブアプリの開発、計算結果の保存機能、ノードロック、集合分析、etc... 私にはこれらすべてを実装するリソースは無さそうなので、どこまで野心をお持ちなのかは分かりませんが、これらの機能も備えたいという場合はぜひそのままソルバーを自作されるのも良いかと思います。もちろん私のソルバーに contribute という形でも大歓迎ですが、やはり自作することそのものの醍醐味は大きいと思いますので。

AlmondAlmond

ありがとうございます。
あまり大きなものを作れる自信はないですが、コードを参考にさせていただいて頑張ってみようと思います。

しぃしぃ

公開おめでとうございます
しがない個人クリエイターです。
質問ですが、本ブログではなく、ソルバー自体へのリンクはリンクフリーでしょうか?
可能なのであれば、自身のサイトにソルバーへのリンクを貼らせて頂きたいと思っています。

b_inaryb_inary

(直リンクでない通常の)リンクは自由であるべきと思っているので、ソルバー、本記事、GitHubレポジトリなどは特に断りなどなしにリンクしていただいてまったく構いません。