|
| 1 | +#!/usr/bin/env python |
| 2 | + |
| 3 | + |
| 4 | +"""" |
| 5 | +Enhance.py |
| 6 | +
|
| 7 | +Run engine bench command and return nodes. |
| 8 | +
|
| 9 | +format: |
| 10 | + bench <hash> <threads> <limitvalue> <fenfile | default> |
| 11 | + <limittype [depth(default), perft, nodes, movetime]> <evaltype [mixed(default), classical, NNUE]> |
| 12 | +
|
| 13 | +bench 128 1 4 file.epd depth mixed |
| 14 | +""" |
| 15 | + |
| 16 | + |
| 17 | +__author__ = 'fsmosca' |
| 18 | +__script_name__ = 'enhance' |
| 19 | +__version__ = 'v0.3.1' |
| 20 | +__credits__ = ['joergoster', 'musketeerchess'] |
| 21 | + |
| 22 | + |
| 23 | +from pathlib import Path |
| 24 | +import subprocess |
| 25 | +import argparse |
| 26 | +import time |
| 27 | +import random |
| 28 | +import concurrent.futures |
| 29 | +from concurrent.futures import ProcessPoolExecutor |
| 30 | +import logging |
| 31 | +from statistics import mean |
| 32 | + |
| 33 | + |
| 34 | +logging.basicConfig( |
| 35 | + filename='enhance_log.txt', filemode='a', |
| 36 | + level=logging.DEBUG, |
| 37 | + format='%(asctime)s - pid%(process)5d - %(levelname)5s - %(message)s') |
| 38 | + |
| 39 | + |
| 40 | +class Enhance: |
| 41 | + def __init__(self, engineinfo, hashmb=64, threads=1, limitvalue=15, |
| 42 | + fenfile='default', limittype='depth', evaltype='mixed', |
| 43 | + concurrency=1, randomizefen=False, posperfile=50): |
| 44 | + self.engineinfo = engineinfo |
| 45 | + self.hashmb = hashmb |
| 46 | + self.threads = threads |
| 47 | + self.limitvalue = limitvalue |
| 48 | + self.fenfile=fenfile |
| 49 | + self.limittype=limittype |
| 50 | + self.evaltype=evaltype |
| 51 | + self.concurrency = concurrency |
| 52 | + self.randomizefen = randomizefen |
| 53 | + self.posperfile = posperfile |
| 54 | + |
| 55 | + self.nodes = None # objective value |
| 56 | + |
| 57 | + def send(self, p, command): |
| 58 | + """ Send msg to engine """ |
| 59 | + p.stdin.write('%s\n' % command) |
| 60 | + logging.debug('>> %s' % command) |
| 61 | + |
| 62 | + def read_engine_reply(self, p, command): |
| 63 | + """ Read reply from engine """ |
| 64 | + self.nodes = None |
| 65 | + for eline in iter(p.stdout.readline, ''): |
| 66 | + line = eline.strip() |
| 67 | + logging.debug('<< %s' % line) |
| 68 | + if command == 'uci' and 'uciok' in line: |
| 69 | + break |
| 70 | + if command == 'isready' and 'readyok' in line: |
| 71 | + break |
| 72 | + # Nodes searched : 3766422 |
| 73 | + if command == 'bench' and 'nodes searched' in line.lower(): |
| 74 | + self.nodes = int(line.split(': ')[1]) |
| 75 | + elif command == 'bench' and 'Nodes/second' in line: |
| 76 | + break |
| 77 | + |
| 78 | + def bench(self, fenfn) -> int: |
| 79 | + """ |
| 80 | + Run the engine, send a bench command and return the nodes searched. |
| 81 | + """ |
| 82 | + folder = Path(self.engineinfo['cmd']).parent |
| 83 | + |
| 84 | + # Start the engine. |
| 85 | + proc = subprocess.Popen(self.engineinfo['cmd'], stdin=subprocess.PIPE, |
| 86 | + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, |
| 87 | + universal_newlines=True, bufsize=1, cwd=folder) |
| 88 | + |
| 89 | + self.send(proc, 'uci') |
| 90 | + self.read_engine_reply(proc, 'uci') |
| 91 | + |
| 92 | + # Set param values to be optimized. |
| 93 | + for k, v in self.engineinfo['opt'].items(): |
| 94 | + self.send(proc, f'setoption name {k} value {v}') |
| 95 | + |
| 96 | + self.send(proc, 'isready') |
| 97 | + self.read_engine_reply(proc, 'isready') |
| 98 | + |
| 99 | + self.send(proc, f'bench {self.hashmb} {self.threads} {self.limitvalue} {fenfn} {self.limittype} {self.evaltype}') |
| 100 | + # self.send(proc, f'bench') |
| 101 | + self.read_engine_reply(proc, 'bench') |
| 102 | + |
| 103 | + # Quit the engine. |
| 104 | + self.send(proc, 'quit') |
| 105 | + |
| 106 | + return self.nodes |
| 107 | + |
| 108 | + def delete_file(self, fn): |
| 109 | + filepath = Path(fn) |
| 110 | + filepath.unlink(missing_ok=True) # python 3.8 missing_ok is added |
| 111 | + logging.info(f'The file {fn} was deleted.') |
| 112 | + |
| 113 | + def generate_files(self): |
| 114 | + """ |
| 115 | + Read the input fen file, convert it to a list, sort it randomly if required. |
| 116 | + If there are 2 concurrencies, 2 files will be created (file0.fen, file1.fen). |
| 117 | + The number of positions in each file is determined by the posperfile option value. |
| 118 | + These files will be used in the bench command as in: |
| 119 | + bench 64 1 12 file0.fen depth mixed |
| 120 | + """ |
| 121 | + t0 = time.perf_counter() |
| 122 | + |
| 123 | + fenlist, filelist = [], [] |
| 124 | + numpos = self.posperfile |
| 125 | + |
| 126 | + fen_file = Path(self.fenfile) |
| 127 | + if not fen_file.is_file(): |
| 128 | + if self.fenfile != 'default': |
| 129 | + print(f'Warning, {self.fenfile} is missing, default will be used!') |
| 130 | + logging.warning(f'{self.fenfile} is missing, default will be used!') |
| 131 | + return filelist |
| 132 | + |
| 133 | + ext = fen_file.suffix |
| 134 | + |
| 135 | + with open(self.fenfile) as f: |
| 136 | + for lines in f: |
| 137 | + fen = lines.rstrip() |
| 138 | + fenlist.append(fen) |
| 139 | + |
| 140 | + # sort randomly this big fen list |
| 141 | + if self.randomizefen: |
| 142 | + random.shuffle(fenlist) |
| 143 | + |
| 144 | + # Extract the specified number of fens per bench run. |
| 145 | + # If concurrency is 2 we will create 2 files namely file0.fen and file1.fen |
| 146 | + for i in range(self.concurrency): |
| 147 | + # i starts at 0 and if numpos is 50: |
| 148 | + # start=0, end=50 |
| 149 | + # fenlist[0:50] will extract the first 50 fens as a list. |
| 150 | + start = i * numpos |
| 151 | + end = (i+1) * numpos |
| 152 | + |
| 153 | + fens = fenlist[start:end] |
| 154 | + fn = f'file{i}{ext}' |
| 155 | + |
| 156 | + # Save the list of fens in the file. |
| 157 | + with open(fn, 'w') as f: |
| 158 | + for fen in fens: |
| 159 | + f.write(f'{fen}\n') |
| 160 | + |
| 161 | + pathandfile = Path(Path().absolute(), fn).as_posix() |
| 162 | + filelist.append(pathandfile) |
| 163 | + |
| 164 | + logging.info(f'numfiles: {len(filelist)}') |
| 165 | + logging.info(f'file generation elapse {time.perf_counter() - t0:0.2f}s') |
| 166 | + |
| 167 | + return filelist |
| 168 | + |
| 169 | + def run(self): |
| 170 | + """ |
| 171 | + Run the engine with bench command to get the nodes searched and |
| 172 | + return it as the objective value. |
| 173 | + """ |
| 174 | + objectivelist, joblist = [], [] |
| 175 | + |
| 176 | + fenfiles = self.generate_files() |
| 177 | + |
| 178 | + # Use Python 3.8 or higher. |
| 179 | + with ProcessPoolExecutor(max_workers=self.concurrency) as executor: |
| 180 | + if len(fenfiles) == 0: |
| 181 | + job = executor.submit(self.bench, 'default') |
| 182 | + joblist.append(job) |
| 183 | + else: |
| 184 | + for fn in fenfiles: |
| 185 | + job = executor.submit(self.bench, fn) |
| 186 | + joblist.append(job) |
| 187 | + |
| 188 | + for future in concurrent.futures.as_completed(joblist): |
| 189 | + try: |
| 190 | + nodes_searched = future.result() |
| 191 | + objectivelist.append(nodes_searched) |
| 192 | + except concurrent.futures.process.BrokenProcessPool as ex: |
| 193 | + logging.exception(f'exception: {ex}') |
| 194 | + raise |
| 195 | + |
| 196 | + # Delete fen files. |
| 197 | + for fn in fenfiles: |
| 198 | + pass # self.delete_file(fn) |
| 199 | + |
| 200 | + logging.debug(f'bench nodes: {objectivelist}') |
| 201 | + logging.debug(f'mean nodes: {int(mean(objectivelist))}') |
| 202 | + logging.debug(f'sum nodes: {sum(objectivelist)}') |
| 203 | + |
| 204 | + # This is used by optimizer to signal that the job is done. |
| 205 | + print(f'total nodes searched from {self.concurrency} workers: {sum(objectivelist)}') |
| 206 | + print('bench done') |
| 207 | + |
| 208 | + |
| 209 | +def define_engine(engine_option_value): |
| 210 | + """ |
| 211 | + Define engine files, and options. |
| 212 | + """ |
| 213 | + optdict = {} |
| 214 | + engineinfo = {'cmd': None, 'opt': optdict} |
| 215 | + |
| 216 | + for eng_opt_val in engine_option_value: |
| 217 | + for value in eng_opt_val: |
| 218 | + if 'cmd=' in value: |
| 219 | + engineinfo.update({'cmd': value.split('=')[1]}) |
| 220 | + elif 'option.' in value: |
| 221 | + # option.QueenValueOpening=1000 |
| 222 | + optn = value.split('option.')[1].split('=')[0] |
| 223 | + optv = value.split('option.')[1].split('=')[1] |
| 224 | + optdict.update({optn: optv}) |
| 225 | + engineinfo.update({'opt': optdict}) |
| 226 | + |
| 227 | + return engineinfo |
| 228 | + |
| 229 | + |
| 230 | +def main(): |
| 231 | + parser = argparse.ArgumentParser( |
| 232 | + formatter_class=argparse.RawTextHelpFormatter, |
| 233 | + prog='%s %s' % (__script_name__, __version__), |
| 234 | + description='Run bench command.', |
| 235 | + epilog='%(prog)s') |
| 236 | + parser.add_argument('-engine', nargs='*', action='append', required=True, |
| 237 | + metavar=('cmd=', 'option.<optionname>=value'), |
| 238 | + help='Define engine filename and option, required=True. Example:\n' |
| 239 | + '-engine cmd=eng.exe option.FutilityMargin=120 option.MoveCount=1000') |
| 240 | + parser.add_argument('-concurrency', required=False, |
| 241 | + help='the number of benches to run in parallel, default=1', |
| 242 | + type=int, default=1) |
| 243 | + parser.add_argument('-hashmb', required=False, help='memory value in mb, default=64', |
| 244 | + type=int, default=64) |
| 245 | + parser.add_argument('-threads', required=False, help='number of threads, default=1', |
| 246 | + type=int, default=1) |
| 247 | + parser.add_argument('-limitvalue', required=False, |
| 248 | + help='a number that limits the engine search, default=15', |
| 249 | + type=int, default=15) |
| 250 | + parser.add_argument('-fenfile', required=False, |
| 251 | + help='Filename of FEN or EPD file, default=default', |
| 252 | + default='default') |
| 253 | + parser.add_argument('-limittype', required=False, |
| 254 | + help='the type of limit can be depth, perft, nodes and movetime, default=depth', |
| 255 | + type=str, default='depth') |
| 256 | + parser.add_argument('-evaltype', required=False, |
| 257 | + help='the type of eval to use can be mixed, classical or NNUE, default=mixed', |
| 258 | + type=str, default='mixed') |
| 259 | + parser.add_argument('-randomizefen', action='store_true', |
| 260 | + help='A flag to randomize position before using it in bench when position file is used.') |
| 261 | + parser.add_argument('-posperfile', required=False, |
| 262 | + help='the number of positions in the file to be used in the bench, default=50', |
| 263 | + type=int, default=50) |
| 264 | + parser.add_argument('-v', '--version', action='version', version=f'{__version__}') |
| 265 | + |
| 266 | + args = parser.parse_args() |
| 267 | + |
| 268 | + # Define engine files, name and options. |
| 269 | + engineinfo = define_engine(args.engine) |
| 270 | + |
| 271 | + # Exit if engine file is not defined. |
| 272 | + if engineinfo['cmd'] is None: |
| 273 | + print('Error, engines are not properly defined!') |
| 274 | + return |
| 275 | + |
| 276 | + tstart = time.perf_counter() |
| 277 | + |
| 278 | + duel = Enhance(engineinfo, hashmb=args.hashmb, threads=args.threads, |
| 279 | + limitvalue=args.limitvalue, fenfile=args.fenfile, |
| 280 | + limittype=args.limittype, evaltype=args.evaltype, |
| 281 | + concurrency=args.concurrency, |
| 282 | + randomizefen=args.randomizefen, |
| 283 | + posperfile=args.posperfile) |
| 284 | + duel.run() |
| 285 | + |
| 286 | + logging.info(f'total elapse time: {time.perf_counter() - tstart:0.2f}s') |
| 287 | + |
| 288 | + |
| 289 | +if __name__ == '__main__': |
| 290 | + main() |
0 commit comments