1414"""
1515from __future__ import annotations
1616
17+ import collections
1718import lzma
1819import os
1920import struct
21+ import threading
2022from typing import Any , Dict , List , Optional , Tuple
2123
2224import 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 )
0 commit comments