Skip to content

Commit 5f7ee3d

Browse files
noobpwnftwclaude
andcommitted
chesstb: reclaim decoded blocks automatically via a shared LRU
The per-color _blocks dicts only ever grew: every decoded WDL/DTC/DTM50 block stayed resident until Tablebase.close(), so a long probing session accumulated all blocks of every opened table. Add _BlockCache, a thread-safe LRU keyed by (per_color, block_id) that tracks an approximate resident-byte total and evicts least-recently-used blocks (dropping them from their owning _blocks dict) once the budget is exceeded. Each table file routes _get_block* through it; Tablebase owns a single shared cache so the budget bounds the whole session rather than each file. New block_cache_bytes kwarg on Tablebase/open_tablebase tunes the budget (default 64 MiB); close() clears it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 68ff0ec commit 5f7ee3d

2 files changed

Lines changed: 108 additions & 10 deletions

File tree

chess/chesstb.py

Lines changed: 92 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,11 @@
1414
"""
1515
from __future__ import annotations
1616

17+
import collections
1718
import lzma
1819
import os
1920
import struct
21+
import threading
2022
from typing import Any, Dict, List, Optional, Tuple
2123

2224
import chess
@@ -714,6 +716,66 @@ def lz4_decompress_block(src: memoryview, expected_size: int, dict_bytes: bytes
714716
return result
715717

716718

719+
# ===========================================================================
720+
# Decoded-block cache
721+
# ===========================================================================
722+
723+
#: Default soft budget (bytes) for decoded blocks held resident across all
724+
#: tables of a :class:`Tablebase`. The cache evicts least-recently-used blocks
725+
#: once the budget is exceeded, so memory is reclaimed automatically without an
726+
#: explicit :meth:`Tablebase.close`.
727+
DEFAULT_BLOCK_CACHE_BYTES = 64 * 1024 * 1024
728+
729+
730+
class _BlockCache:
731+
"""LRU reclaimer shared by every table of a :class:`Tablebase`.
732+
733+
Decoding a block is expensive, so each per-color object keeps its own
734+
``_blocks`` dict of decoded blocks. This cache tracks those entries in a
735+
global least-recently-used order keyed by ``(per_color, block_id)`` and,
736+
once the resident byte estimate exceeds ``max_bytes``, evicts the oldest
737+
blocks by dropping them from their owning ``_blocks`` dict. Sizes are
738+
approximate; the budget is a soft target.
739+
"""
740+
741+
def __init__(self, max_bytes: int) -> None:
742+
self.max_bytes = max_bytes
743+
self.cur_bytes = 0
744+
# (per_color, block_id) -> approximate size in bytes, ordered LRU-first.
745+
self._lru: "collections.OrderedDict[Tuple[Any, int], int]" = collections.OrderedDict()
746+
self._lock = threading.Lock()
747+
748+
def touch(self, pc: Any, block_id: int) -> None:
749+
"""Mark an already-cached block as most-recently-used (a cache hit)."""
750+
key = (pc, block_id)
751+
with self._lock:
752+
if key in self._lru:
753+
self._lru.move_to_end(key)
754+
755+
def record(self, pc: Any, block_id: int, size: int) -> None:
756+
"""Register a freshly decoded block and evict until within budget."""
757+
key = (pc, block_id)
758+
with self._lock:
759+
old = self._lru.pop(key, None)
760+
if old is not None:
761+
self.cur_bytes -= old
762+
self._lru[key] = size
763+
self.cur_bytes += size
764+
# Keep the just-added block (it is at the end); never empty fully.
765+
while self.cur_bytes > self.max_bytes and len(self._lru) > 1:
766+
(ev_pc, ev_id), ev_size = self._lru.popitem(last=False)
767+
self.cur_bytes -= ev_size
768+
ev_pc._blocks.pop(ev_id, None)
769+
770+
def clear(self) -> None:
771+
"""Drop every tracked block and reset the budget."""
772+
with self._lock:
773+
for pc, block_id in self._lru:
774+
pc._blocks.pop(block_id, None)
775+
self._lru.clear()
776+
self.cur_bytes = 0
777+
778+
717779
# ===========================================================================
718780
# WDL table file
719781
# ===========================================================================
@@ -751,9 +813,10 @@ class WDLFile:
751813
EXT = ".lzw"
752814
MAGIC = WDL_MAGIC
753815

754-
def __init__(self, cfg: PieceConfig, path: str):
816+
def __init__(self, cfg: PieceConfig, path: str, cache: Optional[_BlockCache] = None):
755817
self.cfg = cfg
756818
self.index_cfg = position_index_config(cfg)
819+
self.cache = cache if cache is not None else _BlockCache(DEFAULT_BLOCK_CACHE_BYTES)
757820
self.is_singular = [False, False]
758821
self.is_dropped = [False, False]
759822
self.per_color: List[Optional[_WDLPerColor]] = [None, None]
@@ -840,13 +903,15 @@ def _finalize(self, r: _Serial, colors: List[int]) -> None:
840903
def _get_block(self, pc: _WDLPerColor, block_id: int) -> bytes:
841904
blk = pc._blocks.get(block_id)
842905
if blk is not None:
906+
self.cache.touch(pc, block_id)
843907
return blk
844908
doff, dnext = pc.offsets.get2(block_id)
845909
dsz = dnext - doff
846910
out_sz = (pc.tail_size if (block_id == pc.block_cnt - 1 and pc.tail_size != 0)
847911
else pc.block_size)
848912
blk = lz4_decompress_block(pc.compressed[doff:doff + dsz], out_sz, pc.dict)
849913
pc._blocks[block_id] = blk
914+
self.cache.record(pc, block_id, len(blk))
850915
return blk
851916

852917
def read(self, color: int, board: chess.Board) -> int:
@@ -1031,9 +1096,10 @@ class DTCFile:
10311096
EXT = ".lzdtc"
10321097
MAGIC = DTC_MAGIC
10331098

1034-
def __init__(self, cfg: PieceConfig, path: str):
1099+
def __init__(self, cfg: PieceConfig, path: str, cache: Optional[_BlockCache] = None):
10351100
self.cfg = cfg
10361101
self.index_cfg = position_index_config(cfg)
1102+
self.cache = cache if cache is not None else _BlockCache(DEFAULT_BLOCK_CACHE_BYTES)
10371103
self.is_singular = [False, False]
10381104
self.is_dropped = [False, False]
10391105
self.per_color: List[Optional[_RankPerColor]] = [None, None]
@@ -1107,13 +1173,15 @@ def _finalize(self, r: _Serial, colors: List[int]) -> None:
11071173
def _get_block_raw(self, pc: _RankPerColor, block_id: int) -> bytes:
11081174
blk = pc._blocks.get(block_id)
11091175
if blk is not None:
1176+
self.cache.touch(pc, block_id)
11101177
return blk
11111178
decode_sz = (pc.tail_size if (block_id == pc.block_cnt - 1 and pc.tail_size != 0)
11121179
else pc.block_size)
11131180
doff, dnext = pc.offsets.get2(block_id)
11141181
dsz = dnext - doff
11151182
blk = b"" if dsz == 0 else lzma_raw_decompress(pc.compressed[doff:doff + dsz], decode_sz)
11161183
pc._blocks[block_id] = blk
1184+
self.cache.record(pc, block_id, len(blk))
11171185
return blk
11181186

11191187
def read(self, color: int, board: chess.Board, wdl: int) -> int:
@@ -1182,9 +1250,10 @@ class DTM50File:
11821250
EXT = ".lzdtm50"
11831251
MAGIC = DTM50_MAGIC
11841252

1185-
def __init__(self, cfg: PieceConfig, path: str):
1253+
def __init__(self, cfg: PieceConfig, path: str, cache: Optional[_BlockCache] = None):
11861254
self.cfg = cfg
11871255
self.index_cfg = position_index_config(cfg)
1256+
self.cache = cache if cache is not None else _BlockCache(DEFAULT_BLOCK_CACHE_BYTES)
11881257
self.is_singular = [False, False]
11891258
self.is_dropped = [False, False]
11901259
self.per_color: List[Optional[_DTM50PerColor]] = [None, None]
@@ -1263,6 +1332,7 @@ def _finalize(self, r: _Serial, colors: List[int]) -> None:
12631332
def _get_block(self, pc: _DTM50PerColor, block_id: int) -> Dict[str, Any]:
12641333
blk = pc._blocks.get(block_id)
12651334
if blk is not None:
1335+
self.cache.touch(pc, block_id)
12661336
return blk
12671337
doff, dnext = pc.offsets.get2(block_id)
12681338
dsz = dnext - doff
@@ -1321,6 +1391,9 @@ def _get_block(self, pc: _DTM50PerColor, block_id: int) -> Dict[str, Any]:
13211391
"single_short_pre": single_short_pre, "double_short_pre": double_short_pre,
13221392
}
13231393
pc._blocks[block_id] = blk
1394+
# Approximate resident size: the decoded payload plus the per-position
1395+
# Python state/index lists derived above.
1396+
self.cache.record(pc, block_id, len(payload) + 9 * num_positions)
13241397
return blk
13251398

13261399
@staticmethod
@@ -1524,11 +1597,14 @@ class Tablebase:
15241597
derived from the canonical orientation of the board's material.
15251598
"""
15261599

1527-
def __init__(self, directory: str):
1600+
def __init__(self, directory: str, *, block_cache_bytes: int = DEFAULT_BLOCK_CACHE_BYTES):
15281601
self.dirs: Dict[str, List[str]] = {kind: [] for kind in ("wdl", "dtc", "dtm50")}
15291602
self._wdl_cache: Dict[int, Optional[WDLFile]] = {}
15301603
self._dtc_cache: Dict[int, Optional[DTCFile]] = {}
15311604
self._dtm50_cache: Dict[int, Optional[DTM50File]] = {}
1605+
# Shared LRU so decoded blocks are reclaimed automatically once the
1606+
# budget is exceeded, rather than growing for the lifetime of the probe.
1607+
self._block_cache = _BlockCache(block_cache_bytes)
15321608
self.add_directory(directory)
15331609

15341610
def add_directory(self, directory: str) -> None:
@@ -1539,6 +1615,7 @@ def add_directory(self, directory: str) -> None:
15391615

15401616
def close(self) -> None:
15411617
"""Drop all cached decoded blocks and open tables."""
1618+
self._block_cache.clear()
15421619
self._wdl_cache.clear()
15431620
self._dtc_cache.clear()
15441621
self._dtm50_cache.clear()
@@ -1561,21 +1638,21 @@ def _open_wdl(self, cfg: PieceConfig) -> Optional[WDLFile]:
15611638
k = cfg.min_key
15621639
if k not in self._wdl_cache:
15631640
p = self._find("wdl", cfg.name(), WDLFile.EXT)
1564-
self._wdl_cache[k] = WDLFile(cfg, p) if p else None
1641+
self._wdl_cache[k] = WDLFile(cfg, p, self._block_cache) if p else None
15651642
return self._wdl_cache[k]
15661643

15671644
def _open_dtc(self, cfg: PieceConfig) -> Optional[DTCFile]:
15681645
k = cfg.min_key
15691646
if k not in self._dtc_cache:
15701647
p = self._find("dtc", cfg.name(), DTCFile.EXT)
1571-
self._dtc_cache[k] = DTCFile(cfg, p) if p else None
1648+
self._dtc_cache[k] = DTCFile(cfg, p, self._block_cache) if p else None
15721649
return self._dtc_cache[k]
15731650

15741651
def _open_dtm50(self, cfg: PieceConfig) -> Optional[DTM50File]:
15751652
k = cfg.min_key
15761653
if k not in self._dtm50_cache:
15771654
p = self._find("dtm50", cfg.name(), DTM50File.EXT)
1578-
self._dtm50_cache[k] = DTM50File(cfg, p) if p else None
1655+
self._dtm50_cache[k] = DTM50File(cfg, p, self._block_cache) if p else None
15791656
return self._dtm50_cache[k]
15801657

15811658
def _has_any_table(self, cfg: PieceConfig) -> bool:
@@ -2004,7 +2081,12 @@ def probe_dtm50(self, board: chess.Board, rule50: Optional[int] = None) -> Tuple
20042081
return (_WDL_SIGNED[r.dtm50_wdl], r.dtm50)
20052082

20062083

2007-
def open_tablebase(directory: str) -> Tablebase:
2084+
def open_tablebase(directory: str, *,
2085+
block_cache_bytes: int = DEFAULT_BLOCK_CACHE_BYTES) -> Tablebase:
20082086
"""Open a directory tree of chesstb tables (``wdl/``, ``dtc/``, ``dtm50/``
2009-
subdirectories, or table files directly under `directory`)."""
2010-
return Tablebase(directory)
2087+
subdirectories, or table files directly under `directory`).
2088+
2089+
Decoded blocks are kept in a least-recently-used cache bounded by
2090+
`block_cache_bytes`; older blocks are reclaimed automatically once the
2091+
budget is exceeded."""
2092+
return Tablebase(directory, block_cache_bytes=block_cache_bytes)

test.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5013,6 +5013,22 @@ def test_missing_table(self):
50135013
with self.assertRaises(chess.chesstb.MissingTableError):
50145014
tables.probe_wdl(chess.Board("8/8/8/8/8/5k2/3QQQQ1/K7 w - - 0 1"))
50155015

5016+
def test_block_cache_reclaim(self):
5017+
# A tiny budget forces decoded blocks to be evicted from their owning
5018+
# per-color dicts, while answers stay correct.
5019+
with chess.chesstb.open_tablebase("data/chesstb", block_cache_bytes=1) as tables:
5020+
board = chess.Board("8/8/8/5k2/8/8/1Q6/K7 w - - 0 1")
5021+
self.assertEqual(tables.probe_dtm(board), 19)
5022+
self.assertEqual(tables.probe_dtm(board), 19)
5023+
cache = tables._block_cache
5024+
cfg, _ = chess.chesstb.piece_config_from_board(board)
5025+
wdl = tables._open_wdl(cfg)
5026+
# At most a single decoded WDL block stays resident under the budget.
5027+
self.assertLessEqual(sum(len(pc._blocks) for pc in wdl.per_color if pc), 1)
5028+
# close() drops everything tracked.
5029+
tables.close()
5030+
self.assertEqual(cache.cur_bytes, 0)
5031+
50165032

50175033
if __name__ == "__main__":
50185034
verbosity = sum(arg.count("v") for arg in sys.argv if all(c == "v" for c in arg.lstrip("-")))

0 commit comments

Comments
 (0)