from array import array
import os
import shutil
import logging
logger = logging.getLogger('communicator')
from time import sleep
from subprocess import Popen, PIPE
from io import StringIO
import pickle as pickle
import re
import numpy as np
from pathlib import Path
from eon.config import ConfigClass # Typing
[docs]
def bundled_job_name(bundle_dirname, slot):
"""Job id for slot *slot* inside a bundle named after the first job.
Bundles live in a directory named ``{state}_{first_wuid}``. Jobs in
the chunk are consecutive wuids, so slot *n* is ``first_wuid + n``.
"""
parts = str(bundle_dirname).split("_", 1)
if len(parts) != 2:
return str(bundle_dirname)
state, first = parts
try:
return f"{state}_{int(first) + int(slot)}"
except ValueError:
return str(bundle_dirname)
[docs]
def tryint(s):
try:
return int(s)
except:
return s
[docs]
def alphanum_key(s):
""" Turn a string into a list of string and number chunks.
"z23a" -> ["z", 23, "a"]
"""
return [ tryint(c) for c in re.split('([0-9]+)', s) ]
[docs]
def sort_nicely(l):
""" Sort the given list in the way that humans expect.
"""
l.sort(key=alphanum_key)
# MPI may only be constructed once per process; non-MPI communicators are
# cached per ConfigClass instance so dual-config / tests do not cross-wire.
_mpi_communicator = None
_comm_by_config_id = {}
[docs]
def reset_communicators():
"""Clear non-MPI communicator cache (tests / multi-config isolation)."""
global _mpi_communicator
_comm_by_config_id.clear()
# MPI is not reset: mpi4py world may only be initialized once.
[docs]
def get_communicator(config: ConfigClass):
"""Return a communicator for *config*.
*config* is required (no process-wide default). Non-MPI communicators are
memoized by ``id(config)``. MPI uses a single process-wide instance.
"""
global _mpi_communicator
if config is None:
raise TypeError("get_communicator() requires a ConfigClass instance")
if config.comm_type == 'mpi':
if _mpi_communicator is not None:
return _mpi_communicator
comm = MPI(config.path_scratch, config.comm_job_bundle_size, config=config)
_mpi_communicator = comm
return comm
key = id(config)
if key in _comm_by_config_id:
return _comm_by_config_id[key]
if config.comm_type == 'cluster':
comm = Script(config.path_scratch, config.comm_job_bundle_size,
config.comm_script_name_prefix,
config.comm_script_path,
config.comm_script_queued_jobs_cmd,
config.comm_script_cancel_job_cmd,
config.comm_script_submit_job_cmd,
config=config)
elif config.comm_type in ('local_lib', 'inprocess', 'local_inprocess'):
from eon.communicator_inprocess import LocalInProcess
bundle = getattr(config, 'comm_job_bundle_size', 1)
comm = LocalInProcess(config.path_scratch, bundle, config=config)
elif config.comm_type == 'local':
comm = Local(config.path_scratch, config.comm_local_client,
config.comm_local_ncpus, config.comm_job_bundle_size,
config=config)
else:
logger.error(str(config.comm_type)+" is an unknown communicator.")
raise ValueError()
_comm_by_config_id[key] = comm
return comm
[docs]
class CommunicatorError(Exception):
pass
[docs]
class EONClientError(Exception):
"""An eOn client finished without outputting results, it probably crashed."""
pass
RETURN_FILES_MANIFEST = "return_files.dat"
# Legacy harvest (no manifest) keeps these suffixes. A manifest overrides
# the filter so declared .json / .log files arrive and undeclared files do not.
_HARVEST_EXTENSIONS = (".con", ".dat", ".json", ".log")
[docs]
def read_return_files_manifest(jobpath):
"""Return the filenames listed in return_files.dat, or None if absent."""
manifest = Path(jobpath) / RETURN_FILES_MANIFEST
if not manifest.is_file():
return None
names = []
for line in manifest.read_text().splitlines():
name = line.strip()
if not name or name.startswith("#"):
continue
names.append(Path(name).name)
return names
def _read_result_file(path):
return StringIO(Path(path).read_text())
[docs]
def harvest_job_files(jobpath):
"""Harvest one non-bundled job directory into {filename: StringIO}.
When return_files.dat is present, only those names (plus results.dat)
are kept, any extension. A listed file that was never written is
logged and omitted. Without a manifest, keep .con/.dat/.json/.log.
"""
listed = read_return_files_manifest(jobpath)
files = {}
root = Path(jobpath)
if listed is None:
for filename in root.glob("*.*"):
if filename.suffix in _HARVEST_EXTENSIONS:
files[filename.name] = _read_result_file(filename)
return files
wanted = list(listed)
if "results.dat" not in wanted:
wanted.append("results.dat")
seen = set()
for fname in wanted:
if fname in seen:
continue
seen.add(fname)
path = root / fname
if not path.is_file():
logger.warning(
"returnFiles lists %s but it is missing under %s",
fname,
jobpath,
)
continue
files[fname] = _read_result_file(path)
return files
[docs]
class Communicator:
def __init__(self, scratchpath, bundle_size=1, config: ConfigClass = None):
if config is None:
raise TypeError("Communicator requires a ConfigClass instance")
self.config = config
scratch = Path(scratchpath)
if not scratch.is_dir():
scratch.mkdir(parents=True)
self.scratchpath = str(scratch)
self.bundle_size = bundle_size
[docs]
def submit_jobs(self, data, invariants):
'''Throws CommunicatorError if fails.'''
raise NotImplementedError()
[docs]
def get_results(self, results):
'''Returns a list of dictionaries containing the results.'''
raise NotImplementedError()
[docs]
def get_queue_size(self):
'''Returns the number of items waiting to run in the queue.'''
raise NotImplementedError()
[docs]
def queued_search_count(self):
"""Searches waiting, using the bundle size this communicator was built with.
``get_queue_size`` counts bundles. Multiplying by the live config
``comm_job_bundle_size`` is wrong if that value changed after
construction.
"""
return self.get_queue_size() * self.bundle_size
[docs]
def in_progress_search_count(self):
"""Searches running, using this communicator's bundle size."""
nprog = getattr(self, "get_number_in_progress", lambda: 0)()
return nprog * self.bundle_size
[docs]
def cancel_state(self, statenumber):
'''Returns the number of workunits that were canceled.'''
raise NotImplementedError()
[docs]
def get_bundle_size(self, job_path):
if not isinstance(job_path, list):
# List files in job_path.
fnames = os.listdir(job_path)
else:
# job_path is already a list of filenames.
fnames = job_path
# Count results*.dat files.
pattern = re.compile(r'results(?:_\d+)?.dat$')
size = sum(1
for fname in fnames
if pattern.match(fname))
is_bundle = not (size == 1 and "results.dat" in fnames)
return size, is_bundle
[docs]
def unbundle(self, resultpath, keep_result):
'''This method unbundles multiple jobs into multiple single
jobs so the akmc script can process them.
If the job did not return results (probably because it
crashed or was canceled), this method will raise
EONClientError.
'''
# These are the files in the result directory that we keep.
result_root = Path(resultpath)
jobpaths = [str(p) for p in result_root.iterdir() if p.is_dir()]
regex = re.compile(r"(\w+)_(\d+)(\.\w+)")
for jobpath in jobpaths:
dirname = Path(jobpath).name
if not keep_result(dirname):
continue
# Need to figure out how many jobs were bundled together
# and then create the new job directories with the split files.
bundle_size, is_bundle = self.get_bundle_size(jobpath)
if bundle_size == 0:
logger.error("Client running in %s returned no results. "
"Check its output for errors." % jobpath)
# GH: just log the error and continue instead of quitting
#raise EONClientError("Client running in %s returned no results. "
# "Check its output for errors." % jobpath)
continue
results = [
{'name': bundled_job_name(dirname, i)}
for i in range(bundle_size)
]
if not is_bundle:
# Only a single task inside this job, no need to unbundle.
harvested = harvest_job_files(jobpath)
results[0].update(harvested)
results[0]['number'] = 0
else:
# Several tasks bundled inside this job, we need to unbundle.
filenames = list(Path(jobpath).glob("*_[0-9]*.*"))
for filename in filenames:
if filename.suffix not in _HARVEST_EXTENSIONS:
continue
# parse filename
fname = filename.name
match = regex.match(fname)
if not match:
continue
parts = match.groups()
index = int(parts[1])
key = parts[0]+parts[2]
# Load data into stringIO object (should we just return filehandles?)
try:
filedata = StringIO(filename.read_text())
except (IOError, OSError):
logger.exception("Failed to read file %s" % filename)
continue
# add result to results
results[index][key] = filedata
results[index]['number'] = index
kept = []
for slot in results:
if any(str(k).startswith('results') for k in slot):
kept.append(slot)
else:
logger.warning("Failed to find a results.dat file for %s slot %s",
slot.get('name'), slot.get('number'))
yield kept
[docs]
def make_bundles(self, data, invariants):
'''This method is a generator that bundles together multiple jobs into a single job.
Example usage:
for jobpath in self.make_bundles(data, invariants):
do_stuff()'''
from eon import fileio as io
pot_invariants = io.load_potfiles(getattr(self.config, "path_pot", "") or "")
merged = dict(pot_invariants)
if invariants:
merged.update(invariants)
invariants = merged
# Split jobpaths in to lists of size self.bundle_size.
chunks = [ data[i:i+self.bundle_size] for i in range(0, len(data), self.bundle_size) ]
for chunk in chunks:
job_path = Path(self.scratchpath) / chunk[0]["id"]
job_path.mkdir()
for filename, (file_contents, file_permissions) in invariants.items():
dest = job_path / filename
dest.write_text(file_contents.getvalue())
dest.chmod(file_permissions)
# Concatenate all of the displacement and modes together.
n = 0
for job in chunk:
for basename in list(job.keys()):
splitname = basename.rsplit(".", 1)
if len(splitname)!=2:
continue
if self.bundle_size == 1:
filename = basename
else:
filename = "%s_%d.%s" % (splitname[0], n, splitname[1])
(job_path / filename).write_text(job[basename].getvalue())
n += 1
# Returns the jobpath to the new bigger workunit.
yield str(job_path)
[docs]
class MPI(Communicator):
def __init__(self, scratchpath, bundle_size, config: ConfigClass = None):
Communicator.__init__(self, scratchpath, bundle_size, config = config)
from mpi4py.MPI import COMM_WORLD
self.comm = COMM_WORLD
self.client_ranks = [ int(r) for r in os.environ['EON_CLIENT_RANKS'].split(os.pathsep) ]
self.config.comm_job_buffer_size = len(self.client_ranks)
scratch = Path(self.scratchpath)
self.resume_jobs = [p.name for p in scratch.iterdir() if p.is_dir()] if scratch.is_dir() else []
logger.info("Found %i jobs to resume in %s", len(self.resume_jobs), self.scratchpath)
[docs]
def submit_jobs(self, data, invariants):
ready_ranks = self.get_ready_ranks()
for jobpath in self.make_bundles(data, invariants):
rank = ready_ranks.pop()
tmp = np.empty(1, dtype='i')
self.comm.Recv(tmp, source=rank, tag=1)
buf = array('b')
bufval = jobpath+'\0'
buf.frombytes(bufval.encode())
self.comm.Send(buf, rank)
[docs]
def run_resume_jobs(self):
if len(self.resume_jobs) == 0: return
ready_ranks = self.get_ready_ranks()
while True:
if len(self.resume_jobs) == 0: break
if len(ready_ranks) == 0: break
jobdir = self.resume_jobs.pop()
rank = ready_ranks.pop()
jobpath = str(Path(self.scratchpath) / jobdir)
tmp = np.empty(1, dtype='i')
self.comm.Recv(tmp, source=rank, tag=1)
buf = array('b')
bufval = jobpath+'\0'
buf.frombytes(bufval.encode())
self.comm.Send(buf, rank)
[docs]
def get_ready_ranks(self):
ready_ranks = []
for rank in self.client_ranks:
ready = self.comm.Iprobe(rank, tag=1)
if ready:
ready_ranks.append(rank)
return ready_ranks
[docs]
def get_queue_size(self):
self.run_resume_jobs()
nready = len(self.get_ready_ranks())
nclients = len(self.client_ranks)
qs = nclients - nready
return qs
[docs]
def get_results(self, resultspath, keep_result):
'''Moves work from scratchpath to results path.'''
import mpi4py.MPI as MPI
status = MPI.Status()
while self.comm.Iprobe(source=MPI.ANY_SOURCE, tag=0, status=status):
buf = np.array(['\0']*1024, dtype="S1")
self.comm.Recv([buf, MPI.CHARACTER], source=status.source, tag=0)
strterm = np.where(buf == b'\0')
strindex = strterm[0][0]
jobdir = buf[:strindex].tobytes()
jobdir = Path(jobdir.decode()).name
if not (Path(self.scratchpath) / jobdir).is_dir():
logger.error("Client returned %s, which is not in %s; skipping",
jobdir, self.scratchpath)
continue
if self.config.debug_keep_all_results:
shutil.copytree(
Path(self.scratchpath) / jobdir,
Path(self.config.path_root) / self.config.debug_results_path / jobdir,
)
dest_dir = Path(resultspath) / jobdir
shutil.move(str(Path(self.scratchpath) / jobdir), str(dest_dir))
for bundle in self.unbundle(resultspath, keep_result):
for result in bundle:
yield result
[docs]
def get_number_in_progress(self):
return int(os.environ['EON_NUMBER_OF_CLIENTS'])
[docs]
def stop_clients(self):
"""Send STOPCAR to every ready client rank (ClientEON exits on that path)."""
n = 0
for rank in self.client_ranks:
if not self.comm.Iprobe(rank, tag=1):
continue
tmp = np.empty(1, dtype='i')
self.comm.Recv(tmp, source=rank, tag=1)
buf = array('b')
buf.frombytes(b'STOPCAR\0')
self.comm.Send(buf, rank)
n += 1
return n
[docs]
def cancel_state(self, state):
# Work already handed to a client cannot be recalled over this
# protocol; its result carries the old state number and the explorer's
# keep_result filter discards it. Stopping the clients here would
# leave the run without workers for the next state.
return 0
[docs]
def client_environment(base=None):
"""Environment for an eonclient the server starts outside an MPI launcher.
An eonclient built with MPI (rgpot calculator groups) loads libmpi, and
with it UCX, in every run. UCX patches the memory functions as the
library loads, which on conda's OpenMPI costs about 0.4 s per process
against 0.02 s for the job itself. A client the Local communicator
starts is one process with no MPI peers, so the memory events are of no
use to it. An explicit UCX_MEM_EVENTS from the user wins.
"""
env = dict(os.environ if base is None else base)
env.setdefault("UCX_MEM_EVENTS", "no")
return env
[docs]
class Local(Communicator):
def __init__(self, scratchpath, client, ncpus, bundle_size, config: ConfigClass = None):
Communicator.__init__(self, scratchpath, bundle_size, config = config)
# number of cpus to use
self.ncpus = ncpus
# path to the client
client_path = Path(client)
if os.sep in client or "/" in client:
self.client = str(client_path.resolve())
if not Path(self.client).is_file():
logger.error("Can't find client: %s", client)
raise CommunicatorError("Can't find client binary: %s"%client)
else:
import shutil
if client_path.is_file():
self.client = str(client_path.resolve())
elif shutil.which(client) is not None:
self.client = shutil.which(client)
else:
logger.error("Can't find client: %s", client)
raise CommunicatorError("Can't find client binary: %s"%client)
self.joblist = []
import atexit
# don't let clients hang around if the script dies
atexit.register(self.cleanup)
[docs]
def cleanup(self):
'''Kills the running eonclients and everything they started.'''
import signal
for job in self.joblist:
p = job[0]
if p.poll() is not None:
continue
try:
if os.name == 'posix':
os.killpg(p.pid, signal.SIGKILL)
else:
p.kill()
except OSError:
pass
[docs]
def get_results(self, resultspath, keep_result):
'''Moves work from scratchpath to results path.'''
scratch = Path(self.scratchpath)
jobdirs = [p.name for p in scratch.iterdir() if p.is_dir()]
for jobdir in jobdirs:
if self.config.debug_keep_all_results:
shutil.copytree(
scratch / jobdir,
Path(self.config.path_root) / self.config.debug_results_path / jobdir,
)
dest_dir = Path(resultspath) / jobdir
shutil.move(str(scratch / jobdir), str(dest_dir))
for bundle in self.unbundle(resultspath, keep_result):
for result in bundle:
yield result
# Clean out scratch directory
for path_name in scratch.iterdir():
if path_name.is_dir():
shutil.rmtree(path_name)
[docs]
def check_job(self, job):
p, jobpath = job[0], job[1]
# Close stdout/stderr file handles so the files can be read for diagnostics.
for handle in job[2:]:
if handle and not handle.closed:
handle.close()
if p.returncode == 0:
logger.info('Job finished: %s' % jobpath)
return True
else:
rc = p.returncode
# stderr is a file (stderr.dat), not a pipe: never call
# communicate() for it. Read both logs from disk for the failure
# path so a dead/hung client cannot leave the parent blocked on a
# full undrained pipe either.
errmsg = "job failed: %s (return code %s)" % (jobpath, rc)
saw_output = False
for name in ("stderr.dat", "stdout.dat"):
path = Path(jobpath) / name
try:
text = path.read_text().strip()
except OSError:
text = ""
if text:
saw_output = True
lines = text.splitlines()
tail = "\n ".join(lines[-10:])
errmsg += "\n %s (last lines): %s" % (name, tail)
if not saw_output:
errmsg += "\n no output captured; client may have failed to start"
if os.name == 'nt' and (rc < 0 or rc > 0x80000000):
errmsg += " (Windows error code 0x%X may indicate a missing DLL)" % (rc & 0xFFFFFFFF)
logger.warning(errmsg)
[docs]
def submit_jobs(self, data, invariants):
'''Run up to ncpu number of clients to process the work in jobpaths.
The job directories are moved to the scratch path before the calculation
is run. This method doesn't return anything.'''
for jobpath in self.make_bundles(data, invariants):
# move the job directory to the scratch directory
# update jobpath to be in the scratch directory
# Redirect both streams to files. stderr=PIPE with an undrained
# pipe deadlocks once the client writes past one OS pipe buffer
# (~64 KiB on Linux): the child blocks in anon_pipe_write while
# the parent only polls p.poll() and never reads the pipe until
# after exit. FPE floods and long client diagnostics both hit this.
fstdout = open(Path(jobpath) / "stdout.dat", 'w')
fstderr = open(Path(jobpath) / "stderr.dat", 'w')
# A session of its own lets cleanup() reach every process the
# client starts (ExtPot wrappers, srun, mpirun), not just the client.
# That session also forces subprocess down fork or vfork. fork runs
# atfork handlers; eon._forksafe parks the OpenBLAS pool first so
# server_lock is free when this process next factorizes.
p = Popen(self.client, cwd=jobpath, stdout=fstdout, stderr=fstderr,
env=client_environment(),
start_new_session=(os.name == 'posix'))
self.joblist.append((p, jobpath, fstdout, fstderr))
while len(self.joblist) == self.ncpus:
for i in range(len(self.joblist)):
p = self.joblist[i][0]
retval = p.poll()
if retval is None:
continue
else:
self.check_job(self.joblist[i])
self.joblist.pop(i)
break
sleep(0.1)
# wait for everything to finish
for job in self.joblist:
p = job[0]
p.wait()
self.check_job(job)
[docs]
def cancel_state(self, state):
return 0
[docs]
def get_queue_size(self):
return 0
[docs]
def get_number_in_progress(self):
return 0
[docs]
class Script(Communicator):
def __init__(self, scratch_path, bundle_size, name_prefix, scripts_path,
queued_jobs_cmd, cancel_job_cmd, submit_job_cmd, config: ConfigClass = None):
Communicator.__init__(self, scratch_path, bundle_size, config=config)
scripts = Path(scripts_path)
self.queued_jobs_cmd = str(scripts / queued_jobs_cmd)
self.cancel_job_cmd = str(scripts / cancel_job_cmd)
self.submit_job_cmd = str(scripts / submit_job_cmd)
self.job_id_path = str(Path(scratch_path) / "script_job_ids")
self.name_prefix = name_prefix
# read in job ids
try:
f = open(self.job_id_path, "rb")
self.jobids = pickle.load(f)
f.close()
except IOError:
self.jobids = {}
pass
[docs]
def save_jobids(self):
f = open(self.job_id_path, "wb")
pickle.dump(self.jobids, f)
f.close()
[docs]
def get_results(self, resultspath, keep_result):
'''Moves work from scratchpath to results path.'''
# queued_jobs.sh jobid1 jobid2 jobid 3
# the inverse of the jobids returned is
# job dirs needs to map
queued_jobs = self.get_queued_jobs()
finished_jobids = set(self.jobids.keys()) - set(queued_jobs)
finished_eonids = []
for jobid in finished_jobids:
finished_eonids.append(int(self.jobids.pop(jobid)))
scratch = Path(self.scratchpath)
jobdirs = [
p.name
for p in scratch.iterdir()
if p.is_dir() and int(p.name.rsplit("_", 1)[-1]) in finished_eonids
]
#try to return jobs in order
sort_nicely(jobdirs)
for jobdir in jobdirs:
if self.config.debug_keep_all_results:
shutil.copytree(
scratch / jobdir,
Path(self.config.path_root) / self.config.debug_results_path / jobdir,
)
dest_dir = Path(resultspath) / jobdir
shutil.move(str(scratch / jobdir), str(dest_dir))
for bundle in self.unbundle(resultspath, keep_result):
for result in bundle:
yield result
[docs]
def check_command(self, status, output, cmdname):
if status != 0:
logger.error(output)
raise CommunicatorError("'%s' returned a non-zero exit status"%cmdname)
[docs]
def submit_jobs(self, data, invariants):
for jobpath in self.make_bundles(data, invariants):
# submit_job.sh jobname jobpath
# should return a jobid
# need to associate this jobid with our jobid
jobpath = str(Path(jobpath).resolve())
jobname = "%s_%s" % (self.name_prefix, Path(jobpath).name)
eon_jobid = jobname.rsplit('_',1)[-1]
cmd = "%s %s %s" % (self.submit_job_cmd, jobname, jobpath)
p = Popen([self.submit_job_cmd,jobname,jobpath], stdout=PIPE, stderr=PIPE)
output, error = p.communicate()
output = output.decode()
error = error.decode()
status = p.returncode
self.check_command(status, output, cmd)
jobid = int(output.strip())
self.jobids[jobid] = eon_jobid
# Persist after each submit so a crash does not lose job ids.
self.save_jobids()
[docs]
def cancel_state(self, state):
'''Cancel the jobs still in the queue and drop their directories.
Jobs that already left the queue keep their ids and directories, so
the next get_results() harvests them; the explorer's keep_result
filter then decides whether they still count. A cancel that fails
(the job finished in between) is logged, not fatal. Returns the
number of jobs cancelled.
'''
queued = self.get_queued_jobs()
cancelled = 0
scratch = Path(self.scratchpath)
for job_id in queued:
p = Popen([self.cancel_job_cmd, str(job_id)], stdout=PIPE, stderr=PIPE)
output, error = p.communicate()
if p.returncode != 0:
logger.warning("cancel_job for %s failed (exit %i): %s", job_id,
p.returncode, (output + error).decode().strip())
continue
eon_jobid = self.jobids.pop(job_id)
for path in scratch.iterdir():
if path.is_dir() and path.name.rsplit("_", 1)[-1] == str(eon_jobid):
shutil.rmtree(path)
cancelled += 1
self.save_jobids()
return cancelled
[docs]
def get_queued_jobs(self):
p = Popen([self.queued_jobs_cmd,''], stdout=PIPE, stderr=PIPE)
output, error = p.communicate()
output = output.decode()
error = error.decode()
status = p.returncode
self.check_command(status, output, self.queued_jobs_cmd)
queued_job_ids = []
for line in output.split("\n"):
try:
queued_job_ids.append(int(line))
except ValueError:
continue
return list(set(self.jobids).intersection(queued_job_ids))
[docs]
def get_number_in_progress(self):
return 0
[docs]
def get_queue_size(self):
return len(self.get_queued_jobs())