Source code for job_execution.process_accounting

"""Run one process while recording its directly attributable resource use."""

import argparse
import gzip
import json
import os
import platform
import signal
import subprocess
import sys
import threading
import time
import zlib
from datetime import UTC, datetime
from pathlib import Path

from simtools.version import __version__

_STDOUT_LOG_SUFFIX = ".log.gz"
_STDERR_LOG_SUFFIX = ".err.gz"
_ENCODING = "utf-8"
_CONTAINER_ENVIRONMENT_VARIABLES = ("APPTAINER_CONTAINER", "SINGULARITY_CONTAINER")
_CPUINFO_PATH = Path("/proc/cpuinfo")


[docs] def build_accounting_command( command, output_path, role, run_id, model_version=None, log_file=None, input_file=None, ): """Build a command that records resource use for one executable. The returned command preserves the executable's stdin unless ``input_file`` is supplied. This is important for sim_telarray consumers of an eventIO stream. Parameters ---------- command : list[str | pathlib.Path] Executable and arguments to run. output_path : str or pathlib.Path JSON resource-record output path. role : str Process role, such as ``"corsika"`` or ``"sim_telarray"``. run_id : int or str Production run identifier. model_version : str, optional Simulation-model version associated with this process. log_file : str or pathlib.Path, optional Gzip-compressed stdout log file. Stderr is written to a sibling ``.err.gz`` file, replacing the ``.log.gz`` suffix. input_file : str or pathlib.Path, optional File connected to the child's stdin. Returns ------- list[str] Command invoking this module with the supplied process command. """ accounting_command = [ sys.executable, "-m", "simtools.job_execution.process_accounting", "--output", str(output_path), "--role", role, "--run-id", str(run_id), ] if model_version: accounting_command.extend(["--model-version", str(model_version)]) if log_file: accounting_command.extend(["--log-file", str(log_file)]) if input_file: accounting_command.extend(["--input-file", str(input_file)]) return [*accounting_command, "--", *(str(value) for value in command)]
[docs] def run_and_record( command, output_path, role, run_id, model_version=None, log_file=None, input_file=None, env=None, ): """Run a process and write a JSON resource record for its direct PID. On Linux, user CPU time, system CPU time, and peak resident memory are sampled from ``/proc/<pid>``. Other platforms still produce a record with wall-clock duration and clearly identify unavailable measurements. Parameters ---------- command : list[str | pathlib.Path] Executable and arguments to run. output_path : str or pathlib.Path JSON resource-record output path. role : str Process role. run_id : int or str Production run identifier. model_version : str, optional Simulation-model version associated with this process. log_file : str or pathlib.Path, optional Gzip-compressed stdout log file. Stderr is written to a sibling ``.err.gz`` file, replacing the ``.log.gz`` suffix. input_file : str or pathlib.Path, optional File connected to the child's stdin. env : dict, optional Environment overrides for the child process. Returns ------- int Child-process return code. """ output_path = Path(output_path) output_path.parent.mkdir(parents=True, exist_ok=True) start_time = datetime.now(UTC) start_clock = time.perf_counter() process, opened_files, log_writers = _start_process(command, log_file, input_file, env) capture_errors = [] log_threads = _start_log_writers(process, log_writers, capture_errors) cpu_times = None peak_rss_bytes = None try: while process.poll() is None: cpu_times, peak_rss_bytes = _update_measurements(process.pid, cpu_times, peak_rss_bytes) time.sleep(0.05) cpu_times, peak_rss_bytes = _update_measurements(process.pid, cpu_times, peak_rss_bytes) return_code = process.wait() finally: for log_thread, process_output in log_threads: log_thread.join() process_output.close() for opened_file in opened_files: opened_file.close() record = _build_record( command, role, run_id, model_version, process.pid, start_time, datetime.now(UTC), time.perf_counter() - start_clock, cpu_times, peak_rss_bytes, return_code, ) output_path.write_text(json.dumps(record, indent=2, sort_keys=True) + "\n", encoding=_ENCODING) if capture_errors: raise RuntimeError( "Failed to capture simulation output; inspect the available logs." ) from (capture_errors[0]) return return_code
def _start_process(command, log_file, input_file, env): """Start the child process and return it with files needing closure.""" opened_files = [] log_writers = [] stdin = None stdout = None stderr = None try: if input_file: stdin = Path(input_file).open("rb") # pylint: disable=consider-using-with opened_files.append(stdin) if log_file: log_path = Path(log_file) log_path.parent.mkdir(parents=True, exist_ok=True) error_path = log_path.with_name( log_path.name.removesuffix(_STDOUT_LOG_SUFFIX) + _STDERR_LOG_SUFFIX ) for path in (log_path, error_path): log_writer = gzip.open(path, "wb") # pylint: disable=consider-using-with opened_files.append(log_writer) log_writers.append(log_writer) stdout = subprocess.PIPE stderr = subprocess.PIPE # The process and streams must remain open until the monitoring loop completes. # pylint: disable=consider-using-with process = subprocess.Popen( [str(value) for value in command], stdin=stdin, stdout=stdout, stderr=stderr, env=_build_environment(env), ) # pylint: enable=consider-using-with except BaseException: for opened_file in opened_files: opened_file.close() raise return process, opened_files, log_writers def _start_log_writers(process, log_writers, capture_errors): """Drain stdout and stderr concurrently into their separate gzip logs.""" log_threads = [] for process_output, log_writer in zip((process.stdout, process.stderr), log_writers): log_thread = threading.Thread( target=_copy_process_output, args=(process_output, log_writer, process, capture_errors), ) log_thread.start() log_threads.append((log_thread, process_output)) return log_threads def _copy_process_output(process_output, log_writer, process, capture_errors): """Copy one stream, stopping the process if output cannot be retained.""" try: while chunk := process_output.read(1024 * 1024): log_writer.write(chunk) except (OSError, ValueError, zlib.error) as exc: capture_errors.append(exc) try: process.kill() except ProcessLookupError: pass def _build_environment(env): """Return the inherited process environment with optional overrides.""" if env is None: return None process_env = os.environ.copy() process_env.update(env) return process_env def _update_measurements(pid, cpu_times, peak_rss_bytes): """Read the latest direct-child CPU and resident-memory measurements.""" sample = _read_linux_process_sample(pid) if sample is None: return cpu_times, peak_rss_bytes sampled_cpu_times, sampled_rss = sample if sampled_rss is not None: peak_rss_bytes = max(peak_rss_bytes or 0, sampled_rss) return sampled_cpu_times, peak_rss_bytes def _read_linux_process_sample(pid): """Read CPU and resident-memory data for one PID from Linux procfs.""" if platform.system() != "Linux": return None try: stat_fields = ( Path(f"/proc/{pid}/stat").read_text(encoding=_ENCODING).rsplit(")", 1)[1].split() ) status = Path(f"/proc/{pid}/status").read_text(encoding=_ENCODING) except FileNotFoundError, IndexError, PermissionError: return None clock_ticks = os.sysconf(os.sysconf_names["SC_CLK_TCK"]) cpu_times = { "user_seconds": int(stat_fields[11]) / clock_ticks, "system_seconds": int(stat_fields[12]) / clock_ticks, } return cpu_times, _read_peak_rss_bytes(status) def _read_peak_rss_bytes(status): """Return the Linux peak resident set size in bytes from a status payload.""" current_rss = None for line in status.splitlines(): if line.startswith("VmHWM:"): return int(line.split()[1]) * 1024 if line.startswith("VmRSS:"): current_rss = int(line.split()[1]) * 1024 return current_rss def _build_record( command, role, run_id, model_version, pid, start_time, end_time, wall_time_seconds, cpu_times, peak_rss_bytes, return_code, ): """Build one JSON-serializable resource record.""" return { "simtools_version": __version__, "run_id": str(run_id), "role": role, "model_version": model_version, "argv": [str(value) for value in command], "pid": pid, "start_time": start_time.isoformat(), "end_time": end_time.isoformat(), "wall_time_seconds": wall_time_seconds, "user_cpu_seconds": None if cpu_times is None else cpu_times["user_seconds"], "system_cpu_seconds": None if cpu_times is None else cpu_times["system_seconds"], "peak_rss_bytes": peak_rss_bytes, "return_code": return_code, "signal": None if return_code >= 0 else signal.Signals(-return_code).name, "measurement_method": "linux_procfs_direct_pid" if platform.system() == "Linux" else "wall_clock_only", "platform": platform.platform(), **_execution_environment(), } def _execution_environment(): """Return host and container identity needed to interpret resource measurements.""" container_image = next( (os.environ[name] for name in _CONTAINER_ENVIRONMENT_VARIABLES if os.environ.get(name)), None, ) return { "hostname": platform.node() or None, "cpu_model": _read_cpu_model(), "logical_cpu_count": os.cpu_count(), "available_cpu_count": os.process_cpu_count(), "container_image": container_image, } def _read_cpu_model(): """Return the processor model name, or ``None`` when it is unavailable.""" try: cpuinfo = _CPUINFO_PATH.read_text(encoding=_ENCODING) except OSError: return platform.processor() or None fields = {} for line in cpuinfo.splitlines(): key, _, value = line.partition(":") if value.strip(): fields.setdefault(key.strip(), value.strip()) for key in ("model name", "Model"): if key in fields: return fields[key] identifiers = [ f"{key}: {fields[key]}" for key in ("CPU implementer", "CPU part") if key in fields ] return ", ".join(identifiers) or platform.processor() or None def _parse_command_line_arguments(): """Parse process-accounting command-line arguments.""" parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--output", required=True) parser.add_argument("--role", required=True) parser.add_argument("--run-id", required=True) parser.add_argument("--model-version") parser.add_argument("--log-file") parser.add_argument("--input-file") parser.add_argument("command", nargs=argparse.REMAINDER) arguments = parser.parse_args() if arguments.command[:1] == ["--"]: arguments.command = arguments.command[1:] if not arguments.command: parser.error("an executable command is required after '--'") return arguments
[docs] def main(): """Run the requested command and return its exit status.""" arguments = _parse_command_line_arguments() return run_and_record( arguments.command, arguments.output, arguments.role, arguments.run_id, model_version=arguments.model_version, log_file=arguments.log_file, input_file=arguments.input_file, )
if __name__ == "__main__": sys.exit(main())