Simplify relative_age and its constants

STC: https://tests.stockfishchess.org/tests/view/69cf29ed1668971c9da23a5a
LLR: 3.47 (-2.94,2.94) <-1.75,0.25>
Total: 193760 W: 50142 L: 50051 D: 93567
Ptnml(0-2): 534, 21375, 52980, 21448, 543

* Simplify retval and arithmetic semantics
* Remove mixing of int and uint8_t

I find this overall much easier to reason about

(Also rename a DEPTH constant per mstembera's suggestion https://github.com/official-stockfish/Stockfish/pull/5766#issuecomment-2586449296)

closes https://github.com/official-stockfish/Stockfish/pull/6702

No functional change
This commit is contained in:
Dubslow
2026-04-15 20:13:22 +02:00
committed by Joost VandeVondele
parent 9bdf2f8d4c
commit 3cce652564
3 changed files with 72 additions and 78 deletions
+50 -58
View File
@@ -32,33 +32,46 @@
namespace Stockfish {
// TTEntry struct is the 10 bytes transposition table entry, defined as below:
// TTEntry struct is the 10 bytes transposition table entry, defined as:
//
// key 16 bit
// depth 8 bit
// generation 5 bit
// pv node 1 bit
// bound type 2 bit
// generation 5 bit
// move 16 bit
// value 16 bit
// evaluation 16 bit
//
// These fields are in the same order as accessed by TT::probe(), since memory is fastest sequentially.
// Equally, the store order in save() matches this order.
//
// We use `bool(depth8)` as the cheap internal occupancy check, corresponding to `depth == DEPTH_NONE`
// externally, so we offset the internal depth by DEPTH_NONE.
//
// Pv, bound and generation are packed in a single byte.
static constexpr uint8_t GENERATION_BITS = 5;
static constexpr uint8_t GENERATION_MASK = (1 << GENERATION_BITS) - 1;
static constexpr uint8_t BOUND_SHIFT = GENERATION_BITS;
static constexpr uint8_t BOUND_MASK = 0b11 << BOUND_SHIFT;
static constexpr uint8_t PV_SHIFT = BOUND_SHIFT + 2;
static constexpr uint8_t PV_MASK = 1 << PV_SHIFT;
struct TTEntry {
// Convert internal bitfields to external types
TTData read() const {
return TTData{Move(move16), Value(value16),
Value(eval16), Depth(depth8 + DEPTH_ENTRY_OFFSET),
Bound(genBound8 & 0x3), bool(genBound8 & 0x4)};
return TTData{Move(move16),
Value(value16),
Value(eval16),
Depth(DEPTH_NONE + depth8),
Bound((genBound8 & BOUND_MASK) >> BOUND_SHIFT),
bool(genBound8 & PV_MASK)};
}
bool is_occupied() const;
void save(Key k, Value v, bool pv, Bound b, Depth d, Move m, Value ev, uint8_t generation8);
// The returned age is a multiple of GENERATION_DELTA
uint8_t relative_age(const uint8_t generation8) const;
bool is_occupied() const { return bool(depth8); };
void save(Key k, Value v, bool pv, Bound b, Depth d, Move m, Value ev, uint8_t curr_generation);
uint8_t relative_age(const uint8_t curr_generation) const;
private:
friend class TranspositionTable;
@@ -71,55 +84,37 @@ struct TTEntry {
int16_t eval16;
};
// `genBound8` is where most of the details are. We use the following constants to manipulate 5 leading generation bits
// and 3 trailing miscellaneous bits.
// These bits are reserved for other things.
static constexpr unsigned GENERATION_BITS = 3;
// increment for generation field
static constexpr int GENERATION_DELTA = (1 << GENERATION_BITS);
// cycle length
static constexpr int GENERATION_CYCLE = 255 + GENERATION_DELTA;
// mask to pull out generation number
static constexpr int GENERATION_MASK = (0xFF << GENERATION_BITS) & 0xFF;
// DEPTH_ENTRY_OFFSET exists because 1) we use `bool(depth8)` as the occupancy check, but
// 2) we need to store negative depths for QS. (`depth8` is the only field with "spare bits":
// we sacrifice the ability to store depths greater than 1<<8 less the offset, as asserted in `save`.)
bool TTEntry::is_occupied() const { return bool(depth8); }
// Populates the TTEntry with a new node's data, possibly
// overwriting an old position. The update is not atomic and can be racy.
// overwriting an old position. The update is non-atomic and can be racy.
void TTEntry::save(
Key k, Value v, bool pv, Bound b, Depth d, Move m, Value ev, uint8_t generation8) {
Key k, Value v, bool pv, Bound b, Depth d, Move m, Value ev, uint8_t curr_generation) {
// Preserve the old ttmove if we don't have a new one
if (m || uint16_t(k) != key16)
move16 = m;
// Overwrite less valuable entries (cheapest checks first)
if (b == BOUND_EXACT || uint16_t(k) != key16 || d - DEPTH_ENTRY_OFFSET + 2 * pv > depth8 - 4
|| relative_age(generation8))
if (b == BOUND_EXACT || uint16_t(k) != key16 || d - DEPTH_NONE + 2 * pv > depth8 - 4
|| relative_age(curr_generation))
{
assert(d > DEPTH_ENTRY_OFFSET);
assert(d < 256 + DEPTH_ENTRY_OFFSET);
assert(d > DEPTH_NONE);
assert(d - DEPTH_NONE < 256);
assert(curr_generation <= GENERATION_MASK); // TT::new_search() plays nice
key16 = uint16_t(k);
depth8 = uint8_t(d - DEPTH_ENTRY_OFFSET);
genBound8 = uint8_t(generation8 | uint8_t(pv) << 2 | b);
depth8 = uint8_t(d - DEPTH_NONE);
genBound8 = uint8_t(curr_generation | b << BOUND_SHIFT | uint8_t(pv) << PV_SHIFT);
value16 = int16_t(v);
eval16 = int16_t(ev);
}
}
uint8_t TTEntry::relative_age(const uint8_t generation8) const {
// Due to our packed storage format for generation and its cyclic
// nature we add GENERATION_CYCLE (256 is the modulus, plus what
// is needed to keep the unrelated lowest n bits from affecting
// the result) to calculate the entry age correctly even after
// generation8 overflows into the next cycle.
return (GENERATION_CYCLE + generation8 - genBound8) & GENERATION_MASK;
uint8_t TTEntry::relative_age(const uint8_t curr_generation) const {
// Returns this entry's age. We count generations like clocks count hours,
// i.e. we require 0 - 1 == 31. Unsigned subtraction guarantees the required
// borrowing regardless of the upper pv/bound bits.
return (curr_generation - genBound8) & GENERATION_MASK;
}
@@ -128,8 +123,8 @@ TTWriter::TTWriter(TTEntry* tte) :
entry(tte) {}
void TTWriter::write(
Key k, Value v, bool pv, Bound b, Depth d, Move m, Value ev, uint8_t generation8) {
entry->save(k, v, pv, b, d, m, ev, generation8);
Key k, Value v, bool pv, Bound b, Depth d, Move m, Value ev, uint8_t curr_generation) {
entry->save(k, v, pv, b, d, m, ev, curr_generation);
}
@@ -192,34 +187,32 @@ void TranspositionTable::clear(ThreadPool& threads) {
// Returns an approximation of the hashtable
// occupation during a search. The hash is x permill full, as per UCI protocol.
// Only counts entries which match the current generation.
// Only counts entries which are younger than maxAge.
int TranspositionTable::hashfull(int maxAge) const {
int maxAgeInternal = maxAge << GENERATION_BITS;
int cnt = 0;
int cnt = 0;
for (int i = 0; i < 1000; ++i)
for (int j = 0; j < ClusterSize; ++j)
cnt += table[i].entry[j].is_occupied()
&& table[i].entry[j].relative_age(generation8) <= maxAgeInternal;
&& table[i].entry[j].relative_age(generation8) <= maxAge;
return cnt / ClusterSize;
}
void TranspositionTable::new_search() {
// increment by delta to keep lower bits as is
generation8 += GENERATION_DELTA;
++generation8;
// Don't overflow into the other bits of TTEntry::genBound8
generation8 &= GENERATION_MASK;
}
uint8_t TranspositionTable::generation() const { return generation8; }
// Looks up the current position in the transposition
// table. It returns true if the position is found.
// Looks up the current position in the transposition table.
// It returns true if the key is found (which may be a collision), and has non-null data.
// Otherwise, it returns false and a pointer to an empty or least valuable TTEntry
// to be replaced later. The replace value of an entry is calculated as its depth
// minus 8 times its relative age. TTEntry t1 is considered more valuable than
// TTEntry t2 if its replace value is greater than that of t2.
// to be replaced later. The value of an entry is its depth minus 8 times its relative age.
std::tuple<bool, TTData, TTWriter> TranspositionTable::probe(const Key key) const {
TTEntry* const tte = first_entry(key);
@@ -234,12 +227,11 @@ std::tuple<bool, TTData, TTWriter> TranspositionTable::probe(const Key key) cons
// Find an entry to be replaced according to the replacement strategy
TTEntry* replace = tte;
for (int i = 1; i < ClusterSize; ++i)
if (replace->depth8 - replace->relative_age(generation8)
> tte[i].depth8 - tte[i].relative_age(generation8))
if (replace->depth8 - 8 * replace->relative_age(generation8)
> tte[i].depth8 - 8 * tte[i].relative_age(generation8))
replace = &tte[i];
return {false,
TTData{Move::none(), VALUE_NONE, VALUE_NONE, DEPTH_ENTRY_OFFSET, BOUND_NONE, false},
return {false, TTData{Move::none(), VALUE_NONE, VALUE_NONE, DEPTH_NONE, BOUND_NONE, false},
TTWriter(replace)};
}
+17 -15
View File
@@ -37,14 +37,11 @@ struct Cluster;
// thus elo. As a hash table, collisions are possible and may cause chess playing issues (bizarre blunders, faulty mate
// reports, etc). Fixing these also loses elo; however such risk decreases quickly with larger TT size.
//
// `probe` is the primary method: given a board position, we lookup its entry in the table, and return a tuple of:
// 1) whether the entry already has this position
// 2) a copy of the prior data (if any) (may be inconsistent due to read races)
// 3) a writer object to this entry
// The copied data and the writer are separated to maintain clear boundaries between local vs global objects.
// We clearly separate TTData, a local copy of an entry, from TTWriter, which writes to the global table.
// A copy of the data already in the entry (possibly collided). `probe` may be racy, resulting in inconsistent data.
// A copy of the data already in an entry (possibly collided). Probes and reads are racy and non-atomic,
// possibly resulting in inconsistent data.
struct TTData {
Move move;
Value value, eval;
@@ -66,7 +63,8 @@ struct TTData {
};
// This is used to make racy writes to the global TT.
// This is used to make racy, non-atomic writes to the global TT. Writes are not "guaranteed":
// for chess reasons, we may decide the new data is less important than the old.
struct TTWriter {
public:
void write(Key k, Value v, bool pv, Bound b, Depth d, Move m, Value ev, uint8_t generation8);
@@ -83,18 +81,22 @@ class TranspositionTable {
public:
~TranspositionTable() { aligned_large_pages_free(table); }
void resize(size_t mbSize, ThreadPool& threads); // Set TT size
void resize(size_t mbSize, ThreadPool& threads); // Set TT size in MiB
void clear(ThreadPool& threads); // Re-initialize memory, multithreaded
int hashfull(int maxAge = 0)
const; // Approximate what fraction of entries (permille) have been written to during this root search
void
new_search(); // This must be called at the beginning of each root search to track entry aging
uint8_t generation() const; // The current age, used when writing new data to the TT
std::tuple<bool, TTData, TTWriter>
probe(const Key key) const; // The main method, whose retvals separate local vs global objects
TTEntry* first_entry(const Key key)
const; // This is the hash function; its only external use is memory prefetching.
// Approximate what fraction of entries (permille) have been written to during this root search
int hashfull(int maxAge = 0) const;
// `probe` is the primary method: given a board position, we lookup its entry in the table, and return a tuple of:
// 1) whether the entry already had data on this position
// 2) a copy of the prior data, if any (may be self-inconsistent due to read races)
// 3) a writer object to the entry
std::tuple<bool, TTData, TTWriter> probe(const Key key) const;
// The hash function; its only external use is memory prefetching
TTEntry* first_entry(const Key key) const;
private:
friend struct TTEntry;
@@ -102,7 +104,7 @@ class TranspositionTable {
size_t clusterCount;
Cluster* table = nullptr;
uint8_t generation8 = 0; // Size must be not bigger than TTEntry::genBound8
uint8_t generation8 = 0;
};
} // namespace Stockfish
+5 -5
View File
@@ -220,11 +220,11 @@ using Depth = int;
constexpr Depth DEPTH_QS = 0;
// For transposition table entries where no searching at all was done
// (whether regular or qsearch) we use DEPTH_UNSEARCHED, which should thus
// compare lower than any quiescence or regular depth. DEPTH_ENTRY_OFFSET
// is used only for the transposition table entry occupancy check (see tt.cpp),
// and should thus be lower than DEPTH_UNSEARCHED.
constexpr Depth DEPTH_UNSEARCHED = -2;
constexpr Depth DEPTH_ENTRY_OFFSET = -3;
// compare lower than any quiescence or regular depth. DEPTH_NONE is used
// for the transposition table entry occupancy check (see tt.cpp), and
// should thus be lower than DEPTH_UNSEARCHED.
constexpr Depth DEPTH_UNSEARCHED = -2;
constexpr Depth DEPTH_NONE = -3;
// clang-format off
enum Square : uint8_t {