movemaskでbitboardを転置するやつ

このツイートを見かけたんですがやり方が見当たらなくて一瞬考えてしまったたので、備忘録として以下に書き残しておきます。

まず、符号なし64bit整数を8x8 bitboardとみなして転置する方法として、基本的な命令のみを用いるならば以下のように書けます。

uint64_t transpose_bitboard(uint64_t b) {
	//引数が8x8 bitboardだとして、転置して返す。

	uint64_t t;

	t = (b ^ (b >> 7)) & 0x00AA00AA00AA00AAULL;
	b = b ^ t ^ (t << 7);
	t = (b ^ (b >> 14)) & 0x0000CCCC0000CCCCULL;
	b = b ^ t ^ (t << 14);
	t = (b ^ (b >> 28)) & 0x00000000F0F0F0F0ULL;
	b = b ^ t ^ (t << 28);

	return b;
}

上記のコードは、以下のオセロソフトのコードで用いられていました。

github.com

一方、AVX2を使うと等価な処理を以下のようにも書けます。

uint64_t transpose_bitboard_avx2(uint64_t b) {
	//引数が8x8 bitboardだとして、転置して返す。

	const __m256i bb = _mm256_set1_epi64x(b);
	const __m256i x1 = _mm256_sllv_epi64(bb, _mm256_set_epi64x(0, 1, 2, 3));
	const __m256i x2 = _mm256_sllv_epi64(bb, _mm256_set_epi64x(4, 5, 6, 7));
	const int y1 = _mm256_movemask_epi8(x1);
	const int y2 = _mm256_movemask_epi8(x2);

	return (uint64_t(uint32_t(y1)) << 32) + uint64_t(uint32_t(y2));
}

_mm256_movemask_epi8関数は、256bit整数を8bit整数32個として解釈したうえで、各8bit整数の最上位ビットを集めて1つの32bit整数にして返り値とする関数です。なので、64bit整数を0~7bit左シフトしてから_mm256_movemask_epi8関数に与えることで転置ができます。

サンプル音声で誰か当てるやつ

ウマ娘のサンプル音声と声優のサンプル音声が与えられたとき、どのウマ娘がどの声優なのか機械学習で当てられるのかっていうやつを思い出したので、一瞬でちょっとだけやってみました。

Methods

いい感じの解くやつを探す

ディープラーニングのなんかいい感じの学習済みモデルであっさり解けるならそれが一番簡単なので、まずそれを試すことにしました。

調べたところ、Microsoft Azureが「Speaker Recognition」という名前でそういうサービスを提供しているみたいでした。なので「speaker recognition pretrained model」で検索して、いい感じのGitHubリポジトリを見つけました。

github.com

とりあえずこれの"ResCNN Softmax+Triplet trained"モデルを使うことにしました。このモデルがやってくれるであろう事柄を整理すると以下のようになります。

  • 不定長のサンプル音声を入力すると固定長のベクトルが出力される
  • 話者が同じである ⇔ 出力ベクトルの類似度が大きい
  • 話者が学習データに含まれなくてもよい
  • サンプル音声の文がバラバラでもよい

ナリタブライアンウマ娘

次にサンプル音声を集めます。まずウマ娘のほうですが、ウマ娘ポータルサイトに行くと各キャラクターのサンプル音声を聞くことができます。

umamusume.jp

右クリックでダウンロードとかはできないようでしたので、Windowsの動画キャプチャ機能(Win+Gキー)でブラウザの動画を撮って音声を抜き出しました。foobar2000を使って動画からwav音声を抽出してから、Adobe Audition 2021で音声部分の切り抜きとサンプルレート16000モノラルへの変換を行いました。あとdBも適当に上げました。

f:id:eukaryo:20210817152216p:plain
ナリタブライアンウマ娘)のサンプル音声

声優

現在、ナリタブライアンの声を当てているのは衣川里佳さんです。所属事務所のwebサイトで3種類の演技のサンプル音声をダウンロードできます。

firstwind.co.jp

(3番目のサンプル音声がナリタブライアンに一番近いと思いました。)ダウンロードできるのはmp3なので、先程と同様にAdobe Audition 2021を使ってwavへの変換とサンプルレート16000モノラルへの変換を行いました。あと、事務所のwebサイトで隣にいた河野ひよりさんのサンプル音声もネガコンとして用意しました。

以前は相坂優歌さんが声を当てていました。所属事務所のwebサイトで3分くらいのサンプル音声1つをダウンロードできます。このサンプルの中でいろいろな演技をやっています。

aptepro.jp

さしあたり、演技ごとに切り分けたりせずにそのまま入力してみることにしました。

Result

結果は以下の通りです。

ナリタブライアン 衣川里佳1 衣2 衣3 河野ひより1 河2 河3 相坂優歌
1.0 0.656 0.313 0.485 0.174 0.316 0.408 0.391
衣1 0.656 1.0 0.316 0.341 0.504 0.487 0.298 0.371
衣2 0.313 0.316 1.0 0.367 0.392 0.501 0.460 0.306
衣3 0.485 0.341 0.367 1.0 0.234 0.515 0.417 0.149
河1 0.174 0.504 0.392 0.234 1.0 0.592 0.278 0.605
河2 0.316 0.487 0.501 0.515 0.592 1.0 0.424 0.450
河3 0.408 0.298 0.460 0.417 0.278 0.424 1.0 0.302
0.391 0.371 0.306 0.149 0.605 0.450 0.302 1.0

ナリタブライアンのサンプル音声の声優を今回用意した3人の中から当てること」に限って言えば成功しています(声優のサンプル音声7つのうち類似度1位と2位が正解)が、全体的にはうまく機能していないようです。学習済みモデルのGitHubリポジトリの記述によると、同じ話者なら類似度が0.8以上とかになりうるみたいですが、全くそうなっていません。

Discussion

  • TODO:他のウマ娘と声優のサンプル音声も用意して実験する
  • 日本語のデータセットで学習すればより良くなる説
  • Azureのやつを試してみるべき説
  • ウマ娘とは関係なく、声優事務所が各人のサンプル音声を複数公開しているときにどれとどれが同じ人か当てられることをまず目指すべき説

(最初に見つけたGitHubリポジトリのモデルがうまく機能しなかったので更に調べた結果知ったのですが、)speaker recognition のほかに speaker identification とか speaker verification と呼ばれることもあるようです。(verificationは微妙に問題設定が異なりますが、大部分は重なるようです)今回使ったモデルより新しいやつを見つけました。vector embeddingしてくれるわけではないみたいですが…
github.com

YavalathのBitboard

github.com

Yavalathというボードゲームの2人向けルールのAIを、Bitboardを多用して実装しました。思考部分はMCTSで実装してあり、コマンドラインで遊べるようになっています。

盤面の表現

Yavalathは1辺5マスの六角形の盤面を使います。2人で交互に石を置いていき、4目以上並べたら勝ちで、ただし3目並べたら負けというルールです。(同時に満たした場合は勝利条件が優先されます)ちなみに『パイルール』という特殊ルールがオプションとして存在します。これは、先手が最初の手を指した直後に後手が「先手後手を交換するか否か」を選べるというルールです。このゲーム、先手は初手真ん中が圧倒的有利なのですが、パイルールはそれを封じさせます。先手有利だと交換されてしまうので、互角な初手を指すのが最善となるわけです。名前の由来は、「先手がパイを2つに切って後手が選ぶ」ことで両者が公平以上に感じられる分割ができるという逸話からです。(無羨望(envy-free)分割などと呼ばれる研究分野があります。)

……話を戻しますが、Yavalathは1辺5マスの六角形の盤面を使います。これは合計61マスなので、64bit整数1個でBitboardできます。例えば横ラインで4目並んでいる箇所を列挙する関数とか、横ラインが「●□●●」になっている箇所を列挙する関数とかは、以下のように書けます。

//uint64_t型変数がYavalathのビットボードだとする。すなわち、
/*
      1 2 3 4 5
    a . . . . . 6
   b . . . . . . 7
  c . . . . . . . 8
 d . . . . . . . . 9
e . . . . . . . . .
 f . . . . . . . . 9
  g . . . . . . . 8
   h . . . . . . 7
    i . . . . . 6
      1 2 3 4 5
*/
//という盤面(例えば左上はa1、右下はb5などと呼ぶ)だとして、変数が
//変数の最下位ビットが[a1]で、その次のビットが[a2]で、
//0b100000のビットが[b1]で、2^60のビットが[i5]だとする。

//xを2進数表示したときに、1が4個連続している箇所を全て探して、
//その最下位ビットが立っている値を返す。
//この関数はハッカーのたのしみ106ページ図6.5を参考にして書かれた。
uint64_t find_4_or_more_consecutive_bits(uint64_t x) noexcept {
	x = x & (x >> 2);
	x = x & (x >> 1);
	return x;
}

//xとyを2進数表示したときに、xが"1011"になっている箇所で、
//かつその0の箇所でyのビットが立っているような箇所をすべて探して、
//その最下位ビットが立っている値を返す。
//ただし、xとyで同じ箇所にビットが立っていないことを仮定する。
uint64_t find_0b1011(const uint64_t x, const uint64_t y) noexcept {
	const uint64_t xx = (x >> 1) & x;
	return (x >> 3) & (y >> 2) & xx;
}

constexpr uint64_t BB_MASK_4 = 0b000'00011'000111'0001111'00011111'000111111'00011111'0001111'000111'00011ULL;
constexpr uint64_t BB_MASK_3 = 0b000'00111'001111'0011111'00111111'001111111'00111111'0011111'001111'00111ULL;
constexpr uint64_t BB_MASK_2 = 0b000'01111'011111'0111111'01111111'011111111'01111111'0111111'011111'01111ULL;

find_4_or_more_consecutive_bits関数は、Bitboard上では4連続だけれど盤上では4連続ではないケースも拾ってしまいます。それはBB_MASK_4 変数で適宜マスクすれば解決できます。

Bitboardの回転

横ラインで特定のビットパターンになっている箇所を列挙することはできました。しかし、斜めラインはこの方法では扱えません。この課題に対して、今回はBitboardをn*60度(n=1,2,4,5)回転させる関数を用意して対処しました。具体的には、まず盤面を意味する構造体は元のBitboardを0度・60度・120度に傾けた状態を保持しておきます。そして「どれかのラインで特定のビットパターンになっている箇所を全列挙」とかするときは、3方向各々の横ラインを計算してから300度・240度回転させる関数を使って1個の64bit変数にまとめ直します。

この回転処理は、前回の記事で紹介した「ワードの一般的順列演算」に帰着させました。そのうえで、300度・240度回転についてはsag関数4回で計算できることを確認しました。

eukaryote.hateblo.jp

高速な1手詰め・3手詰め関数

任意のビットパターンを高速に列挙できるので、「そこに石を置くと(勝てる/即負けになる/王手になる)座標」とかも列挙できます。これらを用いれば1手詰めや3手詰めの存在を高速に検出できます。これによって、シミュレーションのときに3手詰めを回避できたり見逃さなかったりする、完全なランダムよりちょっと強いプレイヤーでシミュレーションできるわけです。

モンテカルロ木探索(MCTS)

対戦できるようにするための思考ルーチンはMCTSで作りました。いわゆる評価関数(=局面を引数に取り、優劣を予測してスカラー値を返す関数)が無い場合のベターな選択肢だと思われたからです。ただし実際にはそんなに強くなくて、人間側は序盤の手筋を知っていれば簡単に勝ててしまいます。Yavalathは1手差で勝敗が決まりやすいので、ランダムに近いシミュレーションで局面の優劣を正確に評価するのは難しいからだと思われます。

コード中ではMCTS_Solverクラスがこれを実装しています。MCTSは「selection→expansion→simulation→backpropagation」の4ステップから成りますが、これらを割と読みやすい形で実装できたと思っています。

ハッカーのたのしみ第7章7.5の解説とちょっとした改善

algorithm-study/hackers_delight_7_5 at master · eukaryo/algorithm-study · GitHub


ハッカーのたのしみ第7章7.5では、「ワードの一般的順列演算」と呼ばれている演算の実装方法について論じられています。「ワードの一般的順列演算」は、愚直に書くと以下のように書けます。引数xのi桁目のビットをs[i]桁目に移すというものです。

uint64_t permutation_naive(const uint64_t x, const int s[64]) {

	//引数sは0~63の順列だとする。
	for (int i = 0; i < 64; ++i)
		assert(0 <= s[i] && s[i] < 64);
	for (int i = 0; i < 64; ++i)for (int j = i + 1; j < 64; ++j)
		assert(s[i] != s[j]);

	uint64_t answer = 0;

	for (int i = 0; i < 64; ++i) {
		const uint64_t bit = 1ULL << i;
		if (x & bit)answer |= 1ULL << s[i];
	}

	return answer;
}

さて、ハッカーのたのしみ第7章の7.4では、compressと呼ばれる別の演算について説明されています。これはx86ではpextという名前で実装されています。(というか、ハッカーのたのしみがpextについて論じていることを最近まで忘れていました)

「ワードの一般的順列演算」を実装するにあたり、そのcompress演算を使ってsag関数という部品をまず定義します。sag関数は、pextのmaskにて立っている場所のビットを上位桁側にまとめて、それ以外を下位桁側にまとめるというものです。言い換えると、xとmaskを1ビットの64要素の配列として見たとき、maskの値に基づきxを安定ソートしていると解釈できます。

uint64_t compress(uint64_t x, uint64_t mask) {
	return _pext_u64(x, mask);
}
uint64_t sag(uint64_t x, uint64_t mask) {
	return (compress(x, mask) << (_mm_popcnt_u64(~mask)))
		| compress(x, ~mask);
}

次に、順列を定義している64要素の配列を「ビットごとに行列転置」して、64ビット整数6個に変換します。

uint64_t p[6] = {};//さしあたりグローバル変数

void init_p_array(const int x[64]) {

	//引数xは0~63の順列だとする。
	for (int i = 0; i < 64; ++i)
		assert(0 <= x[i] && x[i] < 64);
	for (int i = 0; i < 64; ++i)for (int j = i + 1; j < 64; ++j)
		assert(x[i] != x[j]);

	for (int i = 0; i < 6; ++i)p[i] = 0;

	//2進6桁の値64個からなる配列xを「ビットごとに行列転置」して、
	//2進64桁の値6個の配列pに格納する。
	for (int i = 0; i < 64; ++i) {
		for (uint64_t b = x[i], j = 0; b; ++j, b >>= 1) {
			if (b & 1ULL) {
				p[j] |= 1ULL << i;
			}
		}
	}

	//ハッカーのたのしみ133ページの事前計算
	p[1] = sag(p[1], p[0]);
	p[2] = sag(sag(p[2], p[0]), p[1]);
	p[3] = sag(sag(sag(p[3], p[0]), p[1]), p[2]);
	p[4] = sag(sag(sag(sag(p[4], p[0]), p[1]), p[2]), p[3]);
	p[5] = sag(sag(sag(sag(sag(p[5], p[0]), p[1]), p[2]), p[3]), p[4]);
}

所望の順列を意味する配列をinit_p_arrayの引数に与えると、グローバル変数の配列pに、いい感じのマジックナンバーが格納されます。

そのうえで、sag関数を逐次的に6回呼ぶことで「ワードの一般的順列演算」ができます。(ハッカーのたのしみでは32bitなので5回でしたが、64bitだと6回になります)

uint64_t permutation(uint64_t x) {
	x = sag(x, p[0]);
	x = sag(x, p[1]);
	x = sag(x, p[2]);
	x = sag(x, p[3]);
	x = sag(x, p[4]);
	x = sag(x, p[5]);
	return x;
}

ハッカーのたのしみにも書かれていることですが、このpermutation関数は実質的にradix sortをやっています。これは安定ソートです。

ここからはハッカーのたのしみに書かれていないのですが、所望の順列が良い性質を持っていれば、より良いマジックナンバーを生成できて、permutation関数内でsag関数を実行する回数を5回以下にできます。まず以下の関数によって、所望の順列を意味する配列xを別の配列x_stableに変換します。

void func(const int x[64], int x_stable[64]) {
	int rep = 0;
	for (int count = 0; count < 64; ++rep) {
		for (int i = 0; i < 64; ++i) {
			if (x[i] == count) {
				x_stable[i] = rep;
				count++;
			}
		}
	}
}

以下の表はランダムなxにこの関数を作用させた例です。

添字 0 1 2 3 4 5 6 7
x[] 3 0 4 7 1 5 6 2
x_stable[] 1 0 1 2 0 1 1 0

配列xとx_stableは「ソート対象物を順序付けるもの」なので、ソートが安定であればどちらの順序付けに基づいても同一のソート結果をもたらすのがポイントです。

前述のinit_p_array関数は以下のように書き換える必要があります。

void init_p_array_better(const int x[64]) {

	for (int i = 0; i < 64; ++i)
		assert(0 <= x[i] && x[i] < 64);
	for (int i = 0; i < 64; ++i)for (int j = i + 1; j < 64; ++j)
		assert(x[i] != x[j]);

	int x_stable[64] = {};
	func(x, x_stable);

	for (int i = 0; i < 6; ++i)p[i] = 0;
	for (int i = 0; i < 64; ++i) {
		for (uint64_t b = x_stable[i], j = 0; b; ++j, b >>= 1) {
			if (b & 1ULL) {
				p[j] |= 1ULL << i;
			}
		}
	}

	p[1] = sag(p[1], p[0]);
	p[2] = sag(sag(p[2], p[0]), p[1]);
	p[3] = sag(sag(sag(p[3], p[0]), p[1]), p[2]);
	p[4] = sag(sag(sag(sag(p[4], p[0]), p[1]), p[2]), p[3]);
	p[5] = sag(sag(sag(sag(sag(p[5], p[0]), p[1]), p[2]), p[3]), p[4]);
}

permutation関数内でsag関数を実行すべき回数は、x_stable配列の最大値をmとすると、ceiling(log2(m+1))回となります。

例えば、所望の順列が「16bitごとに逆転させる」というものだったならば、sagは2回で済みます。配列pがp[2]以降全てゼロになるのです。

int main(void) {

	int x[64];
	for (int i = 0; i < 4; ++i)for (int j = 0; j < 16; ++j) {
		x[i * 16 + j] = (3 - i) * 16 + j;
	}

	init_p_array(x);

	std::cout << "p = (";
	for (int i = 0; i < 6; ++i)std::cout << std::hex << p[i] << (i != 5 ? ", " : ")");
	std::cout << std::endl;

	const uint64_t a = 0x1234'5678'9ABC'DEF0ULL;
	const uint64_t b0 = permutation_naive(a, x);
	const uint64_t b1 = permutation(a);

	init_p_array_better(x);

	std::cout << "p = (";
	for (int i = 0; i < 6; ++i)std::cout << std::hex << p[i] << (i != 5 ? ", " : ")");
	std::cout << std::endl;

	const uint64_t b2 = permutation(a);

	std::cout << "a  = " << a << std::endl;
	std::cout << "b0 = " << b0 << std::endl;
	std::cout << "b1 = " << b1 << std::endl;
	std::cout << "b2 = " << b2 << std::endl;

	return 0;
}

以上のプログラムを走らせると、出力は以下の通りになります。

p = (aaaaaaaaaaaaaaaa, aaaaaaaaaaaaaaaa, aaaaaaaaaaaaaaaa, aaaaaaaaaaaaaaaa, 5555555555555555, 5555555555555555)
p = (ffff0000ffff, ffff0000ffff, 0, 0, 0, 0)
a  = 123456789abcdef0
b0 = def09abc56781234
b1 = def09abc56781234
b2 = def09abc56781234

「16bitごとに逆転させる」というのはデモのための例に過ぎなくて、それ自体はビットマスクとシフトとかでやったほうが当然速いです。

今回のsagベースのアプローチに加えて、ビットシフトとかもアリだとしたときに、所望の順列への変換を実現する最速の機械語列を本当は求めたいのですが、これは難しい問題です。

様々なハッシュテーブルたち

Robin Hood Hashing

ハッシュテーブルのinsertクエリにおける衝突処理をオープンアドレス法でやるとします。具体的にはハッシュ値を1ずつ増やして見ていくとします。このとき、既に入っている要素のほうが「本来の場所からの距離が短い」ならば、それを今入れようとしている要素と交換して、取り出した要素を別の場所に入れるために衝突処理を続けるというのがRobin Hood Hashing(ロビンフッドハッシュ法)です。

ロビンフッドは裕福な人の財産を盗んで貧しい人に与えた伝説の人物で、この命名においては本来の場所からの距離が短いことを裕福さに例えています。

google scholarで"Robin Hood Hashing"で調べたところ)初出は1985年の学会論文のようで、その筆頭著者の学位論文が1986年だったようです。後の人々は学位論文のほうを引用していることが多いです。

ja.wikipedia.org

codecapsule.com

insertクエリでRobin Hood Hashingを採用すると、insertクエリ自体の処理は遅くなりますが、findクエリは速くなります。要素が見つかるまでの探索距離が短くなる(=比較回数が減る)からです。

tombstone法

ハッシュテーブルの衝突処理をオープンアドレス法でやるとすると、eraseクエリ(Keyを引数として、それがテーブル中にあれば削除する)をどう処理するかが問題になります。findして見つけた番地を単に空白にしてしまってはいけません。なぜなら、「その番地を通り過ぎる形でinsertされた要素」が存在する場合、今後その要素をfindしようとしたとき、途中の番地に空白ができていると辿り着けなくなるからです。

対処法は2通りあります。一つはその番地に「削除済み」の印をつけておく方法です。findでは通り過ぎることにして、insertでは再代入可能とします。これをtombstone法と呼びます。(tombstoneは墓石のことです)

もう一つは、次の番地に「本来の場所からずれた要素」が入っているかを調べ、Yesなら交換してまた次を見るという処理をノームソートのように繰り返す方法です。この方法だとfindクエリが少し速くなりますが、交換回数が非常に多くなる可能性があります。(リハッシュの条件などによりますが)たぶんtombstone法のほうが良いと思います。

Swiss Tables

まず歴史的背景として、C++の標準ライブラリは(互換性と引き換えに)ハッシュテーブルの処理速度が遅いことで知られていました。そのためGoogle内部ではC++標準ライブラリの代用とするための高速なライブラリを内製していて、後にAbseilという名前で公開されました。

Swiss Tablesは、Abseilのハッシュテーブルの実装で使われているテクニックです。ハッシュテーブル本体とは別に、各要素8bitのメタデータのテーブルを持っておくというものです。(複数形なのはたぶんそのためでしょう)

8bitのうち最上位ビットはその要素が空白なら1で埋まっているなら0とします。残りの7ビットは(添字番号とは独立な)ハッシュ値とします。こうすることで、x86の拡張命令を上手く使って高速にスクリーニングでき、findクエリを高速化できます。

Swiss Tablesでtombstone法を使う場合(というか、Swiss Tablesを用いたハッシュテーブルを自作するときにeraseクエリをtombstone法で受け付けることにした場合)、メタデータ上で空白と墓石を区別する必要があることに注意しましょう。

abseil.io

abseil.io

abseil.io

コード例

ハッシュテーブルとメタデータ(signatureと呼ぶことにします)とハッシュ関数が以下のようにあるとします。

std::vector<std::pair<Key, Val>>hash_table;
std::vector<uint8_t>signature_table;
extern uint64_t hashfunc(const Key &k);

ただし、両テーブルの大きさ(vectorのsize)は2^{N}+31だとします。空っぽのテーブルを作る初期化処理は以下のように書けます。最小値を10にしたのは何となくです。57にしたのは一応理由があって、ハッシュ値の上位7bitをsignatureに使うからです。

uint64_t bitmask, tablesize;
constexpr uint8_t EMPTY_SIGNATURE = 0x80;
void init(int N) {
	N = std::clamp(N, 10, 57);
	bitmask = (1ULL << N) - 1;
	tablesize = (1ULL << N) + 31;
	hash_table = std::vector<std::pair<Key, Val>>(tablesize);
	signature_table = std::vector<uint8_t>(tablesize, EMPTY_SIGNATURE);
}

insertクエリのコードは省略しますが、格納されている任意の要素について、本来の場所からの距離が31以下であることを仮定します。31以下にできない場合はリハッシュするとします。(Robin Hood Hashingは必須ではありませんが、採用すればテーブルがかなりギチギチになるまでリハッシュせずに済むでしょう)

そのうえで、findクエリは以下のように書けます。

uint64_t find(const Key &k) {

	//ハッシュテーブルに引数Keyの情報があるか調べて、あればその添字番号を返し、なければ-1を返す。

	const uint64_t hashcode = hashfunc(k);
	const uint64_t index = hashcode & bitmask;
	const uint8_t signature = uint8_t(hashcode >> 57);

	const __m256i query_signature = _mm256_set1_epi8(int8_t(signature));
	const __m256i table_signature = _mm256_loadu_si256((__m256i*)&signature_table.data()[index]);

	//[index+i]の情報が ↑のsignature_table.i8[i]に格納されているとして、↓のi桁目のbitに移されるとする。

	const uint32_t is_empty = _mm256_movemask_epi8(table_signature);
	const uint32_t is_positive = _mm256_movemask_epi8(_mm256_cmpeq_epi8(query_signature, table_signature));

	//[index+i]がシグネチャ陽性かどうかがis_positiveの下からi番目のビットにあるとする。

	uint32_t to_look = (is_empty ^ (is_empty - 1)) & is_positive;

	//最初に当たる空白要素より手前にあるシグネチャ陽性な要素の位置のビットボードが計算できる。to_lookがそれである。

	for (uint32_t i = 0; bitscan_forward(to_look, &i); to_look &= to_look - 1) {
		const uint64_t pos = index + i;
		if (k == hash_table[pos].first)return pos;
	}

	return 0xFFFF'FFFF'FFFF'FFFFULL;
}

ひとつめの見どころはtable_signature変数です。signature配列のうち「本来の場所からの距離が31以下」の領域を_mm256_loadu_si256で一発でロードしています。

ふたつめは_mm256_movemask_epi8関数です。こいつは__m256i型変数を8bit変数32個の配列とみなしたうえで、最上位ビットをかき集めてint型にして返してくれる凄いやつです。空白の要素のメタデータ値を0x80にして、要素のシグネチャを7bitにした理由はこの関数があるからです。

これらにより「最大32要素の線形探索」を「7bitのシグネチャでスクリーニングして、陽性だった要素のうち空白より手前の領域だけの探索」にできます。しかも該当する要素のビットボードが得られるので、bsf命令で効率的になめることができます。

一気に構築するときにinsertクエリを使わずに済ます方法

KeyとValのペアが大量に与えられて、ハッシュテーブルを一気に構築することを考えます。コンストラクタがそのデータを受け取るみたいなケースもそうですが、リハッシュにもこの想定が当てはまります。

ナイーブに考えると、空っぽのハッシュテーブルを用意してから各要素についてinsertクエリを発行すれば構築できます。しかしinsertクエリは空白の番地を探す処理などを含むため、この方法は非常に重い処理になります。しかも構築可能な最小のNは非自明なので、insertが途中で失敗して更にリハッシュするみたいな事態も考えられます。

分布数えソートのように度数表を作ってからある種の動的計画法を計算することで、空白要素の探索を一切行わずに、最小の大きさで、かつあたかもRobin Hood Hashingされたかのような(距離の総和が最小な)構築ができます。

uint64_t pow2_ceiling_log2(uint64_t x) {
	//x以上の整数のうち最小の2べき数を返す。
	if (x == 0)return 1;
	--x;
	x |= x >> 1;
	x |= x >> 2;
	x |= x >> 4;
	x |= x >> 8;
	x |= x >> 16;
	x |= x >> 32;
	return x + 1;
}
void init(const std::vector<std::pair<Key, Val>> &data) {
	//引数dataが入っている状態のハッシュテーブルを構築する。

	//まず各データのハッシュ値を計算する。
	std::vector<uint64_t>hashcodes(data.size());
	for (int i = 0; i < data.size(); ++i) {
		hashcodes[i] = hashfunc(data[i].first);
	}

	for (int N = std::max(int(_mm_popcnt_u64(pow2_ceiling_log2(data.size()) - 1)), 10); N <= 57; ++N) {

		bitmask = (1ULL << N) - 1;

		//度数表を作る。
		std::vector<uint64_t>count((1ULL << N), 0);
		for (uint64_t i = 0; i < hashcodes.size(); ++i) {
			++count[hashcodes[i] & bitmask];
		}

		//各添字番号について、その要素が実際に格納され始める位置を求める。
		//32以上離れることがあれば、それはinsert失敗を意味するのでcontinueする。
		bool flag = true;
		std::vector<uint64_t>start_pos((1ULL << N), 0);
		for (uint64_t i = 1; i < (1ULL << N); ++i) {
			start_pos[i] = std::max(start_pos[i - 1] + count[i - 1], i); // main DP
			if (start_pos[i] - i >= 32) {
				flag = false;
				break;
			}
		}
		if (!flag)continue;
		if (start_pos[(1ULL << N) - 1] + count[(1ULL << N) - 1] >= (1ULL << N) + 32)continue;

		//
		//insertが失敗しないことが分かったので、ハッシュテーブルを構築する。
		//

		tablesize = (1ULL << N) + 31;
		hash_table = std::vector<std::pair<Key, Val>>(tablesize);
		signature_table = std::vector<uint8_t>(tablesize, EMPTY_SIGNATURE);

		for (int i = 0; i < data.size(); ++i) {
			const uint64_t hashcode = hashcodes[i] & bitmask;
			hash_table[start_pos[hashcode]] = data[i];
			signature_table[start_pos[hashcode]] = uint8_t(hashcodes[i] >> 57);
			assert(start_pos[hashcode] - hashcode < 32ULL);
			++start_pos[hashcode];
		}

		return;
	}

	throw std::exception("HashTable size > 2^58");
}

Study Notes on Counterfactual Regret Minimization

こいこいのナッシュ均衡解を求めたいと思い、Counterfactual Regret Minimization (CFR) の論文とその発展手法の論文をいくつか読み、内容を日本語でまとめるというのを最近やっていました。分量が多くなったので本文はここではなくGitHubにpdfで載せました。

https://github.com/eukaryo/algorithm-study/blob/master/CFR_japanese_document.pdf

補足とかを以下に書いていきます。

論文以外で参考になった記事

全部「Counterfactual Regret Minimization」でググって出てきたやつ or そこから辿ったやつです。

多人数不完全情報ゲームにおけるAI ~ポーカーと麻雀を例として~

Counterfactual Regret Minimaization(CFR)の基礎 - Qiita

http://modelai.gettysburg.edu/2013/cfr/cfr.pdf

思ったこと

  • 前から思っていたことですが、中核的な関数のシュードコードだけ書いて、上のレイヤーがその関数をどう呼ぶのか書かないのは良くないと改めて思いました。例えば私の記事のシュードコードにCFR_GET_PROBABILITY関数というのがあって、学習後の推論ではこの関数を呼ぶわけですが、こういうところまで明示してある論文は少ないです。
  • 一変数関数としての\max(\cdot,0)は全部{\rm ReLU}(\cdot)で置き換えてしまったほうが読みやすいだろうなと思いつつ、別の意味で混乱しそうだったので控えました。
  • (懺悔)私はまだBlackwellの接近性定理が何なのか分かっていません。CFR論文のAppendixにはBlackwellの接近性定理を使った証明が載っていますが、Blackwellの接近性定理自体の説明はしておらず、引用で済ませていました。どこかの別分野では教科書レベルのやつなのかもしれませんが、追えていません。
  • Double Neural CFR論文のICLR版は一段組なのですが、小さいFigureみたいな組版のAlgorithm(紙面の右側だけで10行とかのやつ)があってカッコいいです。あと、Scienceに載った先行研究のDeepStackというソフトの再現実験に苦労したという愚痴みたいな話が書いてあって面白かったです。

rank/selectのselect1でpdepを使う

以前、pdep命令がRyzenでめっちゃ遅いことについて調べました。
pdep、pdep抜きで - ubiquitinブログ

その記事のベンチマークではpdepの使いみちについては特に書きませんでした。圧縮全文索引の話で出てくるrank/selectデータ構造のselect1関数をpdepで高速に計算できるらしいということを最近知ったので、確かめてみました。

FM-Indexのことが懐かしくなったので実装してみた - ubiquitinブログ

select1関数の実装

select1関数は、整数xのうち下位から数えてcount番目に立っているビットの位置を返す関数です。ナイーブには以下のように書けます。(引数countは0-originだとします)

int select1_naive(uint64_t x, int count) {

	assert(0 <= count && count < 64);
	assert(_mm_popcnt_u64(x) > count);

	for (int n = 0, i = 0; i < 64; ++i) {
		if (x & (1ULL << i)) {
			if (count == n++)return i;
		}
	}

	assert(0);
	return -1;
}

popcnt命令で二分探索することもできます。多分遅いと思いますが。(以降、最初と最後のassertとかは省略します)

int select1_popcnt_binarysearch(uint64_t x, int count) {

	int lb = 0, ub = 64;
	while (lb + 1 < ub) {
		const int mid = (lb + ub) / 2;
		const int pop = _mm_popcnt_u64(x & ((1ULL << mid) - 1));
		if (pop <= count) lb = mid;
		else ub = mid;
	}
	return lb;
}

整数xの「最下位のビットを取り除く」処理をcount回やってからbsf命令で答えを得る方法もあります。

int select1_bsf(uint64_t x, int count) {

	for (int i = 0; i < count; ++i)x &= x - 1;
	uint32_t index = 0;
	bitscan_forward64(x, &index);
	return int(index);
}

pdep命令の第一引数に2^{count}を入れて、mask(第二引数)にxを入れることで、『「最下位のビットを取り除く」処理をcount回やる』がpdep命令一発で済みます。

int select1_pdep(uint64_t x, int count) {

	const uint64_t answer_bit = _pdep_u64(1ULL << count, x);
	uint32_t index = 0;
	bitscan_forward64(answer_bit, &index);
	return int(index);
}

ベンチマーク結果

遺伝研スパコンのEPYC7702(Zen2世代、2.0GHz)とXeonGold6136(SkyLake世代、3.0GHz)で実験しました。
xをxorshift64で生成してselect1するのを2^{30}回繰り返すのにかかる時間を測定しました。
実験に使ったコードは全てGitHubにあげてあります。

pdep-senpai/select1 at master · eukaryo/pdep-senpai · GitHub

種類 EPYC7702での時間(s) XeonGold6136での時間(s)
naive 71.241 75.074
popcnt 18.311 18.594
bsf 7.713 8.397
pdep 45.809 1.769

pdep/pext命令は一見意味不明ですが独特の中毒性があって、慣れてくると様々な場所で使いたくなってきてしまいます。Zen3世代で改善されているか気になります。