Skip to main content

figrid_board/
transposition.rs

1//! α-β 탐색용 Transposition Table.
2//!
3//! Zobrist 키로 인덱싱된 sparse table. 기존 VCT 전용 TT와 별도로
4//! α-β 노드의 결과를 캐시해 같은 포지션 재평가를 줄인다. Replacement는
5//! depth-preferred + always-replace 혼합 (간단한 2-슬롯 bucket).
6//!
7//! 한 entry는 16 bytes — bucket 2개면 32 bytes. 64K bucket = 2 MB로
8//! 시작 (figrid Piskvork submission 메모리 여유 안에).
9
10use crate::board::Move;
11use std::cell::Cell;
12
13/// TT entry value의 bound 종류.
14/// - Exact: PV 노드, 정확한 score
15/// - Lower: fail-high (score >= beta), 실제는 더 클 수 있음
16/// - Upper: fail-low (score <= alpha), 실제는 더 작을 수 있음
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18#[repr(u8)]
19pub enum Bound {
20    Exact = 0,
21    Lower = 1,
22    Upper = 2,
23}
24
25/// 한 entry. 16 바이트.
26#[derive(Debug, Clone, Copy)]
27pub struct TtEntry {
28    pub key: u64,       // 8 bytes — 충돌 검증용 full key
29    pub score: i32,     // 4 bytes
30    pub depth: u8,      // 1 byte — 저장 시점의 depth
31    pub bound: Bound,   // 1 byte
32    pub best_move: u16, // 2 bytes — Move (usize) 의 u16 표현. NUM_CELLS=225라 OK.
33}
34
35impl TtEntry {
36    pub const EMPTY: Self = TtEntry {
37        key: 0,
38        score: 0,
39        depth: 0,
40        bound: Bound::Exact,
41        best_move: u16::MAX, // sentinel — "no move"
42    };
43
44    #[inline]
45    pub fn is_empty(&self) -> bool {
46        self.best_move == u16::MAX && self.key == 0
47    }
48}
49
50/// 2-슬롯 bucket (depth-preferred + always-replace).
51#[derive(Debug, Clone, Copy)]
52struct Bucket {
53    depth_pref: TtEntry,
54    always_replace: TtEntry,
55}
56
57const EMPTY_BUCKET: Bucket = Bucket {
58    depth_pref: TtEntry::EMPTY,
59    always_replace: TtEntry::EMPTY,
60};
61
62/// 진단용 카운터 — TT 효과 측정. `Cell`이라 `&self probe()`에서도 갱신 가능.
63/// 비용은 매 probe/store당 ~1ns. 정상 동작에 영향 없음.
64#[derive(Debug, Default, Clone, Copy)]
65pub struct TtStats {
66    pub probes: u64,               // probe() 호출 총수
67    pub hits: u64,                 // 키 일치한 횟수 (cutoff 가능 여부 별개)
68    pub stores: u64,               // store() 호출 총수
69    pub displaced_depth_pref: u64, // depth_pref slot이 비어있지 않은데 덮어쓴 횟수
70    pub stored_to_always: u64, // always_replace slot에 저장한 횟수 (= depth_pref가 더 깊어서 보존)
71    /// 저장 시점의 depth 분포 (0..15는 그 depth, 15는 15+ 통합).
72    pub depth_hist: [u64; 16],
73}
74
75pub struct TranspositionTable {
76    buckets: Vec<Bucket>,
77    mask: usize, // index = key as usize & mask (buckets.len()는 2^N)
78    probes: Cell<u64>,
79    hits: Cell<u64>,
80    stores: Cell<u64>,
81    displaced_depth_pref: Cell<u64>,
82    stored_to_always: Cell<u64>,
83    depth_hist: [Cell<u64>; 16],
84}
85
86impl TranspositionTable {
87    /// `bucket_count_pow2`: 버킷 수 = 2^N. 예: 16 → 65 536 버킷 = 2 MB.
88    pub fn new(bucket_count_pow2: u32) -> Self {
89        let n = 1usize << bucket_count_pow2;
90        Self {
91            buckets: vec![EMPTY_BUCKET; n],
92            mask: n - 1,
93            probes: Cell::new(0),
94            hits: Cell::new(0),
95            stores: Cell::new(0),
96            displaced_depth_pref: Cell::new(0),
97            stored_to_always: Cell::new(0),
98            depth_hist: Default::default(),
99        }
100    }
101
102    /// 모든 슬롯 비우기 (탐색 시작 시). 카운터는 보존 — search() 호출 누적.
103    pub fn clear(&mut self) {
104        for b in self.buckets.iter_mut() {
105            b.depth_pref = TtEntry::EMPTY;
106            b.always_replace = TtEntry::EMPTY;
107        }
108    }
109
110    /// 진단 카운터 리셋 (한 search() 단위 측정 시작 시).
111    pub fn reset_stats(&self) {
112        self.probes.set(0);
113        self.hits.set(0);
114        self.stores.set(0);
115        self.displaced_depth_pref.set(0);
116        self.stored_to_always.set(0);
117        for c in &self.depth_hist {
118            c.set(0);
119        }
120    }
121
122    pub fn stats(&self) -> TtStats {
123        let mut hist = [0u64; 16];
124        for (i, c) in self.depth_hist.iter().enumerate() {
125            hist[i] = c.get();
126        }
127        TtStats {
128            probes: self.probes.get(),
129            hits: self.hits.get(),
130            stores: self.stores.get(),
131            displaced_depth_pref: self.displaced_depth_pref.get(),
132            stored_to_always: self.stored_to_always.get(),
133            depth_hist: hist,
134        }
135    }
136
137    /// Bucket 점유율 — `(non-empty depth_pref 수, non-empty always_replace 수, 총 bucket 수)`.
138    /// search 끝난 직후 호출하면 saturation 측정 가능.
139    pub fn occupancy(&self) -> (usize, usize, usize) {
140        let mut dp = 0usize;
141        let mut ar = 0usize;
142        for b in &self.buckets {
143            if !b.depth_pref.is_empty() {
144                dp += 1;
145            }
146            if !b.always_replace.is_empty() {
147                ar += 1;
148            }
149        }
150        (dp, ar, self.buckets.len())
151    }
152
153    /// Lookup. 키 일치하는 entry 있으면 반환.
154    #[inline]
155    pub fn probe(&self, key: u64) -> Option<TtEntry> {
156        self.probes.set(self.probes.get() + 1);
157        let bucket = &self.buckets[(key as usize) & self.mask];
158        if bucket.depth_pref.key == key && !bucket.depth_pref.is_empty() {
159            self.hits.set(self.hits.get() + 1);
160            return Some(bucket.depth_pref);
161        }
162        if bucket.always_replace.key == key && !bucket.always_replace.is_empty() {
163            self.hits.set(self.hits.get() + 1);
164            return Some(bucket.always_replace);
165        }
166        None
167    }
168
169    /// Store. depth-preferred slot에 더 깊거나 같으면 저장, 아니면
170    /// always-replace slot으로.
171    ///
172    /// Push-down 정책 (0.6.2~): depth_pref에 새 entry가 들어가야 할 때
173    /// 기존 entry가 비어있지 않으면 그걸 always-replace로 밀어낸다. 이전
174    /// 정책은 displaced된 entry를 그냥 버려서 always-replace slot이 거의
175    /// 비어있는 채로 활용 안 됨 (진단 2026-04-27: always 사용률 2.1%).
176    /// Push-down 후엔 always-replace가 "두 번째로 좋은 entry" 역할을 함.
177    #[inline]
178    pub fn store(
179        &mut self,
180        key: u64,
181        score: i32,
182        depth: u8,
183        bound: Bound,
184        best_move: Option<Move>,
185    ) {
186        self.stores.set(self.stores.get() + 1);
187        let depth_idx = (depth as usize).min(15);
188        let h = &self.depth_hist[depth_idx];
189        h.set(h.get() + 1);
190        let entry = TtEntry {
191            key,
192            score,
193            depth,
194            bound,
195            best_move: best_move.map(|m| m as u16).unwrap_or(u16::MAX),
196        };
197        let bucket = &mut self.buckets[(key as usize) & self.mask];
198        if bucket.depth_pref.is_empty() || depth >= bucket.depth_pref.depth {
199            if !bucket.depth_pref.is_empty() {
200                self.displaced_depth_pref
201                    .set(self.displaced_depth_pref.get() + 1);
202                // Push-down: 같은 키 update가 아닐 때만 always-replace로 보존.
203                // 같은 키면 새 entry가 갱신본이라 기존을 보존할 이유 없음.
204                if bucket.depth_pref.key != key {
205                    bucket.always_replace = bucket.depth_pref;
206                }
207            }
208            bucket.depth_pref = entry;
209        } else {
210            self.stored_to_always.set(self.stored_to_always.get() + 1);
211            bucket.always_replace = entry;
212        }
213    }
214
215    pub fn capacity(&self) -> usize {
216        self.buckets.len() * 2
217    }
218
219    /// Prefetch the bucket for `key` into L1 cache. Hides ~50-100 ns of
220    /// memory-load latency before the actual `probe()` lands. Call this
221    /// in the search loop as soon as you know the *next* zobrist key
222    /// (e.g. via `current_zob ^ stone_key ^ ZOBRIST_SIDE`) but before
223    /// committing the move + recursing.
224    ///
225    /// `_MM_HINT_T0` = "load into all cache levels" (most aggressive).
226    /// Spurious prefetches are harmless on x86_64; on non-x86 the call
227    /// is a no-op.
228    #[inline]
229    pub fn prefetch(&self, key: u64) {
230        #[cfg(target_arch = "x86_64")]
231        unsafe {
232            use std::arch::x86_64::{_MM_HINT_T0, _mm_prefetch};
233            let idx = (key as usize) & self.mask;
234            let bucket_ptr = self.buckets.as_ptr().add(idx);
235            _mm_prefetch(bucket_ptr as *const i8, _MM_HINT_T0);
236        }
237        #[cfg(not(target_arch = "x86_64"))]
238        let _ = key;
239    }
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245
246    #[test]
247    fn store_and_probe_roundtrip() {
248        let mut tt = TranspositionTable::new(4); // 16 buckets
249        assert!(tt.probe(0xDEAD_BEEF).is_none());
250        tt.store(0xDEAD_BEEF, 123, 5, Bound::Exact, Some(42));
251        let e = tt.probe(0xDEAD_BEEF).expect("should hit");
252        assert_eq!(e.score, 123);
253        assert_eq!(e.depth, 5);
254        assert_eq!(e.bound, Bound::Exact);
255        assert_eq!(e.best_move, 42);
256    }
257
258    #[test]
259    fn depth_preferred_slot_keeps_higher_depth() {
260        let mut tt = TranspositionTable::new(4);
261        // 같은 bucket index를 강제하기 위해 mask 안 들어가는 상위 비트만 다른 키들.
262        let k1 = 0x0000_0000_0000_0000u64; // bucket 0
263        let k2 = 0x0000_0001_0000_0000u64; // 같은 bucket 0 (mask=15)
264        tt.store(k1, 100, 6, Bound::Exact, Some(1));
265        tt.store(k2, 200, 4, Bound::Exact, Some(2));
266        // k1 (depth 6)이 depth_pref에 남고, k2 (depth 4)는 always_replace로.
267        assert_eq!(tt.probe(k1).unwrap().score, 100);
268        assert_eq!(tt.probe(k2).unwrap().score, 200);
269        // 더 얕은 깊이로 k1 update 시도 → always_replace로 밀려남
270        tt.store(k1, 50, 2, Bound::Exact, Some(3));
271        let probed = tt.probe(k1).unwrap();
272        // depth_pref는 여전히 100/depth 6
273        assert_eq!(probed.score, 100);
274    }
275}