Skip to content

Commit 8a468de

Browse files
chore: add helper scripts from Andy Grant (#170)
bench: 1049220
1 parent 28115e3 commit 8a468de

File tree

3 files changed

+294
-0
lines changed

3 files changed

+294
-0
lines changed

scripts/batched_execution_pool.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import multiprocessing
2+
import typing
3+
4+
5+
class BatchedExecutionPool:
6+
def __init__(
7+
self,
8+
input_generator: typing.Generator,
9+
process_function: typing.Callable[..., typing.Any],
10+
process_function_args: typing.Optional[typing.Iterable[typing.Any]],
11+
) -> None:
12+
self.input_generator = input_generator
13+
self.process_function = process_function
14+
self.process_function_args = process_function_args
15+
16+
def execute(self, threads: int, batchsize: int) -> int:
17+
in_queue = multiprocessing.Queue()
18+
out_queue = multiprocessing.Queue()
19+
20+
workers = [
21+
multiprocessing.Process(
22+
target=BatchedExecutionPool._process_function_wrapper,
23+
args=(
24+
in_queue,
25+
out_queue,
26+
self.process_function,
27+
self.process_function_args,
28+
),
29+
daemon=True,
30+
)
31+
for f in range(threads)
32+
]
33+
34+
for worker in workers:
35+
worker.start()
36+
37+
while True:
38+
n = self._enqueue_elements(batchsize, in_queue)
39+
for f in range(n):
40+
yield out_queue.get()
41+
if n != batchsize:
42+
break
43+
44+
for f in range(threads):
45+
in_queue.put(None)
46+
47+
for worker in workers:
48+
worker.join()
49+
50+
def _enqueue_elements(self, batchsize: int, in_queue: multiprocessing.Queue) -> int:
51+
for f in range(batchsize):
52+
try:
53+
in_queue.put(next(self.input_generator))
54+
except StopIteration:
55+
return f
56+
return batchsize
57+
58+
@staticmethod
59+
def _process_function_wrapper(
60+
in_queue: multiprocessing.Queue,
61+
out_queue: multiprocessing.Queue,
62+
process_function: typing.Callable[..., typing.Any],
63+
process_function_args: typing.Optional[typing.Iterable[typing.Any]],
64+
) -> None:
65+
while (data := in_queue.get()) != None:
66+
out_queue.put(process_function(data, process_function_args))

scripts/replay.py

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
#!/usr/bin/env python3
2+
3+
import argparse
4+
import chess
5+
import chess.pgn
6+
import sys
7+
import traceback
8+
import io
9+
10+
from subprocess import Popen, PIPE
11+
from batched_execution_pool import BatchedExecutionPool
12+
13+
class Engine():
14+
15+
def __init__(self, binary):
16+
self.engine = Popen([binary], stdin=PIPE, stdout=PIPE, universal_newlines=True, shell=True)
17+
self.uci_ready()
18+
19+
def write_line(self, line):
20+
self.engine.stdin.write(line)
21+
self.engine.stdin.flush()
22+
23+
def read_line(self):
24+
return self.engine.stdout.readline().rstrip()
25+
26+
def uci_ready(self):
27+
self.write_line('isready\n')
28+
while self.read_line() != 'readyok': pass
29+
30+
def quit(self):
31+
self.write_line('quit\n')
32+
33+
def parse_args():
34+
35+
p = argparse.ArgumentParser(description='Add UCI options with: --option.Name=Value')
36+
p.add_argument('--engine', type=str, required=True, help='Path to the engine or engine name')
37+
p.add_argument('--pgn', type=str, required=True, help='Path to the PGN file')
38+
p.add_argument('--player', type=str, required=True, help='Name of the player')
39+
args, unknown = p.parse_known_args()
40+
41+
uci_options = []
42+
for value in unknown:
43+
if '=' in value and value.startswith('--option.'):
44+
uci_options.append(value[len('--option.'):].split('='))
45+
46+
return args, uci_options
47+
48+
def convert_uci_to_score(score_type, score_value):
49+
50+
if score_type == 'cp' and int(score_value) == 0:
51+
return '0.00'
52+
53+
if score_type == 'cp':
54+
return '%+.2f' % (float(score_value) / 100.0)
55+
56+
if score_type == 'mate' and int(score_value) < 0:
57+
return '-M%d' % (abs(2 * int(score_value)))
58+
59+
if score_type == 'mate' and int(score_value) > 0:
60+
return '+M%d' % (abs(2 * int(score_value) - 1))
61+
62+
raise Exception('Unable to process Score (%s, %s)' % (score_type, score_value))
63+
64+
65+
def game_generator(args):
66+
with open(args.pgn) as pgn_file:
67+
while game := chess.pgn.read_game(pgn_file):
68+
yield (str(game))
69+
70+
def replay_game(game_str, args):
71+
72+
game = chess.pgn.read_game(io.StringIO(game_str))
73+
process_args, uci_options = args
74+
75+
is_white = game.headers.get('White') == process_args.player
76+
is_black = game.headers.get('Black') == process_args.player
77+
assert is_white or is_black
78+
79+
fen = game.headers.get('FEN', None)
80+
pos = 'position fen %s moves' % (fen) if fen else 'position startpos moves'
81+
82+
engine = Engine(process_args.engine)
83+
for opt, value in uci_options:
84+
engine.write_line('setoption name %s value %s\n' % (opt, value))
85+
86+
node = game
87+
while node.variations:
88+
89+
next_node = node.variation(0)
90+
91+
if (node.turn() and is_white) or (not node.turn() and is_black):
92+
93+
try:
94+
pgn_score, pgn_depths, pgn_timems, pgn_nodes = next_node.comment.split()
95+
pgn_nodes = int(pgn_nodes)
96+
97+
except Exception:
98+
break
99+
100+
engine.uci_ready()
101+
engine.write_line('%s\ngo nodes %d\n' % (pos, pgn_nodes))
102+
103+
score = None
104+
while 'bestmove' not in (line := engine.read_line()):
105+
if ' score ' in line:
106+
score = line.split(' score ')[1].split('nodes ')[0].split()[:2]
107+
best_move = line.split()[1]
108+
109+
try:
110+
assert best_move == next_node.move.uci()
111+
assert convert_uci_to_score(*score) == pgn_score
112+
except AssertionError:
113+
engine.quit()
114+
return 'Failed: ' + game_str
115+
116+
pos += ' ' + next_node.move.uci()
117+
node = next_node
118+
119+
engine.quit()
120+
121+
if __name__ == '__main__':
122+
123+
args, uci_options = parse_args()
124+
125+
pool = BatchedExecutionPool(
126+
input_generator = game_generator(args),
127+
process_function = replay_game,
128+
process_function_args = [args, uci_options],
129+
)
130+
131+
for result in pool.execute(threads=15, batchsize=256):
132+
if result:
133+
print (result)

scripts/reproduce.py

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
#!/usr/bin/env python3
2+
3+
# Given a .pgn file containing a single game, attempt to create a series of UCI commands
4+
# for the specified player, which aims to perfectly recreate the game, down to the node.
5+
#
6+
# Bugs, oddities, or just interesting situations, are often seen in PGNs from games played.
7+
# Those games are almost always played with standard Fischer time controls. As a result,
8+
# reproducing those games can be very challenging.
9+
#
10+
# FastChess, and OpenBench's fork of cutechess, supply the node counters most recently
11+
# reported by the playing engine in the PGN. Engines like Torch, which always produce
12+
# a final UCI report at the end of the search, and which have perfect determinism for
13+
# "go nodes <x>", are able to replay games if the exact node counters are known.
14+
#
15+
# This script makes use of a UCI extension, a command called "wait", which will block
16+
# until the current search is done. That allows piping the output of the script, directly
17+
# into an engine as stdin, to set up the engine state.
18+
#
19+
# Suppose that we have a pgn where Torch played an illegal move as white, during an SPRT
20+
# test. We could get a series of commands to reproduce the engine state, up to the final
21+
# search where the illegal move occurred, with the following:
22+
# ./reproduce.py --pgn bug.pgn --white --nodes --option.Hash=16
23+
24+
import argparse
25+
import chess
26+
import chess.pgn
27+
28+
def iterate_uci_options(unknown):
29+
for arg in unknown:
30+
if '=' in arg and arg.startswith('--option.'):
31+
yield arg[len('--option.'):].split('=')
32+
33+
def parse_args():
34+
35+
p = argparse.ArgumentParser(description='Add UCI options with: --option.Name=Value')
36+
p.add_argument('--pgn', type=str, required=True, help='.pgn with only a single game')
37+
38+
# Must pick between replicating game with fixed nodes or fixed depth
39+
p.add_argument('--nodes', action='store_true', help='Generate commands using "go nodes"')
40+
p.add_argument('--depth', action='store_true', help='Generate commands using "go depth"')
41+
42+
# Provide commands only for the desired colour
43+
p.add_argument('--white', action='store_true', help='Generate commands for White')
44+
p.add_argument('--black', action='store_true', help='Generate commands for Black')
45+
46+
args, unknown = p.parse_known_args()
47+
48+
if args.nodes == args.depth:
49+
raise Exception('Must use either --nodes or --depth')
50+
51+
if args.white == args.black:
52+
raise Exception('Must use either --white or --black')
53+
54+
return args, iterate_uci_options(unknown)
55+
56+
def main():
57+
58+
args, uci_options = parse_args()
59+
60+
with open(args.pgn) as pgn_file:
61+
game = chess.pgn.read_game(pgn_file)
62+
63+
if not game:
64+
raise Exception('Empty PGN file')
65+
66+
print ('uci')
67+
for opt_name, opt_value in uci_options:
68+
print ('setoption name %s value %s' % (opt_name, opt_value))
69+
print ('ucinewgame')
70+
print ('isready')
71+
72+
fen = game.headers.get('FEN', None)
73+
pos = 'position fen %s moves' % (fen) if fen else 'position startpos moves'
74+
75+
node = game
76+
while node.variations:
77+
78+
next_node = node.variation(0)
79+
80+
if (node.turn() and args.white) or (not node.turn() and args.black):
81+
82+
print (pos)
83+
84+
try: score, depths, timems, nodes = next_node.comment.split()
85+
except: exit()
86+
depth, seldepth = depths.split('/')
87+
88+
print ('go depth %s' % (depth) if args.depth else 'go nodes %s' % (nodes))
89+
print ('wait')
90+
91+
pos += ' ' + next_node.move.uci()
92+
node = next_node
93+
94+
if __name__ == '__main__':
95+
main()

0 commit comments

Comments
 (0)