The Stockfish chess engine needs to store, for its evaluation, both an endgame score and a middlegame score.
Instead of storing them separately, it packs them into one int
. The middlegame score is stored in the lower 16 bits. The endgame score is stored in the higher 16 bits, as-is if the middlegame score is positive or minus one if it is negative.
This has the advantage that operations (addition, subtraction, negation and multiplication) can be done for both numbers in parallel.
/// Score enum stores a middlegame and an endgame value in a single integer (enum).
/// The least significant 16 bits are used to store the middlegame value and the
/// upper 16 bits are used to store the endgame value. We have to take care to
/// avoid left-shifting a signed int to avoid undefined behavior.
enum Score : int { SCORE_ZERO };
constexpr Score make_score(int mg, int eg) {
return Score((int)((unsigned int)eg << 16) + mg);
}
/// Extracting the signed lower and upper 16 bits is not so trivial because
/// according to the standard a simple cast to short is implementation defined
/// and so is a right shift of a signed integer.
inline Value eg_value(Score s) {
union { uint16_t u; int16_t s; } eg = { uint16_t(unsigned(s + 0x8000) >> 16) };
return Value(eg.s);
}
inline Value mg_value(Score s) {
union { uint16_t u; int16_t s; } mg = { uint16_t(unsigned(s)) };
return Value(mg.s);
}
#define ENABLE_BASE_OPERATORS_ON(T) \
constexpr T operator+(T d1, int d2) { return T(int(d1) + d2); } \
constexpr T operator-(T d1, int d2) { return T(int(d1) - d2); } \
constexpr T operator-(T d) { return T(-int(d)); } \
inline T& operator+=(T& d1, int d2) { return d1 = d1 + d2; } \
inline T& operator-=(T& d1, int d2) { return d1 = d1 - d2; }
ENABLE_BASE_OPERATORS_ON(Score)
/// Only declared but not defined. We don't want to multiply two scores due to
/// a very high risk of overflow. So user should explicitly convert to integer.
Score operator*(Score, Score) = delete;
/// Division of a Score must be handled separately for each term
inline Score operator/(Score s, int i) {
return make_score(mg_value(s) / i, eg_value(s) / i);
}
/// Multiplication of a Score by an integer. We check for overflow in debug mode.
inline Score operator*(Score s, int i) {
Score result = Score(int(s) * i);
assert(eg_value(result) == (i * eg_value(s)));
assert(mg_value(result) == (i * mg_value(s)));
assert((i == 0) || (result / i) == s);
return result;
}
I understand how addition, subtraction and negation work, but what I have trouble understanding is multiplication. How does multiplying the integer multiplies both the endgame and the middlegame scores together correctly?
Here's a proof, assuming addition works which you already understand.
It involves some algebra, so here's some handy notation for that. All variables are signed 16-bit values, 2's complement representation, unless stated otherwise.
S(eg,mg) = [eg - neg(mg), mg]
Here
eg
is an endgame score, andmg
is a midgame score and[,]
shows the bits of values packed into a 32-bit 2's complement signed integer. Most significant bits are to the left.S(eg,mg)
is the representation of those scores. Hereneg(x)
is 1 if x is negative and 0 otherwise, so that the requirement that the representation ofeg
in the upper bits has one subtracted ifmg
is negative is satisfied.This is what
make_score
should do (bug — for example it is not consistent witheg_value
for negativemg
).We want to show that for any 32-bit signed integer
i
, multiplyingS(eg,mg)
byi
just models multiplyingeg
andmg
byi
, which can be precisely stated as(T)
i*S(eg,mg) = S(i*eg, i*mg)
where 16-bit multiplications are used in the arguments.Proof: If i=0 this is obvious. Suppose that i>0 and that addition works, i.e. adding representations adds their endgame scores and adds their midgame scores. i.e. suppose
(A)
S(eg1,mg1) + S(eg2,mg2) = S(eg1+eg2,mg1+mg2)
for any arguments.Then repeated use of (A) establishes (T) as follows.
So now (T) is established for all but negative
i
values.Suppose
i < 0
, and leti = -j
. Thenso (T) is true for negative
i
provided it is true fori = -1
, which is proved below.This is the crux of the matter: working for scaling by (-1), i.e. proving that
-S(eg,mg) = S(-eg,-mg)
. The proof below completes the proof of (T), solving the problem. Here's the argument:and we want to show this equal to
and here's some algebra that does just that. It makes liberal use of the well known 2's complement identity that
-a = ~a + 1
, i.e. the arithmetic negation is one more than the bitwise negation.