温故知新(続Transposition Table編)

前回のTransposition Table編に続きます。

 

bleu48.hatenablog.com

 

前回はTransposition Table実装をgpt-oss頼りに行ったという話で、当然ながら結構効果的なものでした。

ただ、Python実装なのでStockfishのような特殊なメモリ管理をしていないという話でしたが、この部分もgpt-ossに詳しく聞くことができました。

以下の回答が結構驚きです。

 

Stichfish準拠のTTとC++標準のunordered_mapのパフォーマンス比較として、ベンチマークプログラムとベンチマーク結果を出力してくれました。

以下、少し修正して動くようになったプログラムです。

#include <chrono>
#include <random>
#include <unordered_map>
#include <vector>
#include <cstring>
#include <immintrin.h>   // for _mm_clflush (optional)

using Key = uint64_t;

inline unsigned long long __builtin_clzll(unsigned long long x) { unsigned long r; _BitScanReverse64(&r, x); return 63 - r; }

// ---- TT の最小実装 -------------------------------------------------
constexpr size_t ClusterSize = 4;           // 4‑way
struct TTEntry {
    uint16_t move;
    int16_t  value;
    uint8_t  depth;
    uint8_t  genBound;   // 2bit bound + 6bit generation
    uint16_t key16;
};

struct TTCluster { TTEntry e[ClusterSize]; };

class SimpleTT {
    std::vector<TTCluster> table;
    size_t mask;            // (clusterCount-1)
    uint8_t generation = 0;

public:
    explicit SimpleTT(size_t mb) {
        size_t clusters = (mb * 1024 * 1024) / sizeof(TTCluster);
        // 2 のべき乗に丸める
        clusters = 1ULL << (63 - __builtin_clzll(clusters));
        table.resize(clusters);
        mask = clusters - 1;
    }

    // プローブだけ。ヒットしたらエントリへのポインタ、外れたら nullptr
    TTEntry* probe(Key k) {
        size_t idx = (k >> 16) & mask;
        uint16_t k16 = uint16_t(k >> 48);
        TTCluster& cl = table[idx];
        for (int i = 0; i < ClusterSize; ++i)
            if (cl.e[i].key16 == k16) return &cl.e[i];
        // 置換エントリを探す(最古・浅いもの)
        TTEntry* victim = &cl.e[0];
        for (int i = 1; i < ClusterSize; ++i) {
            TTEntry* e = &cl.e[i];
            bool older = (e->genBound & 0x3F) < (victim->genBound & 0x3F);
            bool shallower = (e->depth < victim->depth) &&
                             *1;
            if (older || shallower) victim = e;
        }
        // クリアして返す
        std::memset(victim, 0, sizeof(TTEntry));
        victim->key16 = k16;
        victim->genBound = generation;      // generation だけ設定
        return victim;
    }

    void incGen() { generation = (generation + 1) & 0x3F; }
};

// ---- ベンチマーク ---------------------------------------------------
int main() {
    const size_t N = 1'000'000;               // キー数
    const size_t ITER = 100'000'000;          // プローブ回数
    std::mt19937_64 rng(0xDEADBEEF);
    std::vector<Key> keys(N);
    for (auto &k : keys) k = rng();

    // 1. TT
    SimpleTT tt(64);                          // 64 MiB ≈ 1M エントリ
    for (Key k : keys) tt.probe(k);           // 予め全エントリを登録

    auto t0 = std::chrono::high_resolution_clock::now();
    uint64_t dummy = 0;
    for (size_t i = 0; i < ITER; ++i) {
        Key k = keys[i % N];
        TTEntry* e = tt.probe(k);
        dummy += e->key16;                    // 防止最適化
    }
    auto t1 = std::chrono::high_resolution_clock::now();
    double ttTime = std::chrono::duration<double>(t1 - t0).count();

    // 2. unordered_map
    std::unordered_map<Key, uint16_t> umap;
    umap.reserve(N * 2);
    for (Key k : keys) umap.emplace(k, 0);

    t0 = std::chrono::high_resolution_clock::now();
    for (size_t i = 0; i < ITER; ++i) {
        Key k = keys[i % N];
        dummy += umap[k];                     // 同様に dummy で最適化防止
    }
    t1 = std::chrono::high_resolution_clock::now();
    double umapTime = std::chrono::duration<double>(t1 - t0).count();

    printf("TT   : %.3f s (%.1f ns/op)\n", ttTime, ttTime * 1e9 / ITER);
    printf("unordered_map: %.3f s (%.1f ns/op)\n", umapTime,
           umapTime * 1e9 / ITER);
    printf("dummy=%llu\n", (unsigned long long)dummy);
    return 0;
}
 

 

LLMが吐くベンチマーク結果はもちろんいい加減なものですが、ベンチマークプログラムは少し修正しただけで意味のある動作をしており、3倍近い速度差が得られました。

それにしても他分野に比較して異常とも思えるほどStockfishには詳しいようです。

 

---

おまけ:

Go言語実装しているエンジン(俗称:地ビール)にも以前からTT実装してありましたが、独自実装から部分的にStockfish準拠にしたものに変更したものを作成しました。

全体のベンチマークにはあまり影響がないようですが、バージョン0.13としてそのうちFloodgateデビューすると思います。

 

 

 

 

*1:e->genBound & 0x3F) == (victim->genBound & 0x3F