Source code for job_execution.backends.local

"""Local process execution backend."""

import logging
import os
from concurrent.futures import ProcessPoolExecutor, as_completed
from multiprocessing import get_all_start_methods, get_context
from pathlib import Path

from simtools.constants import SCIENCE_TEST_SCHEMA_PATH
from simtools.data_model import schema
from simtools.job_execution.backends.base import BackendConfigurationError, BackendExecutionError
from simtools.job_execution.job import JobResult, SubmissionHandle
from simtools.job_execution.worker import execute_job_spec

logger = logging.getLogger(__name__)

_CONFIG_KEYS = {"mp_start_method"}


[docs] def determine_max_workers(max_workers=None, default_fraction=0.6): """Determine the local worker count.""" cpu_count = os.cpu_count() or 1 if max_workers is None: return max(1, int(cpu_count * default_fraction)) return max_workers if max_workers > 0 else cpu_count
[docs] class LocalBackend: """Execute job specifications with local worker processes.""" supports_submit_only = False
[docs] def submit(self, job_specs, options): """Submit jobs to a local process pool.""" self._validate_config(options.backend_config) item_list = list(job_specs) workers = determine_max_workers(options.max_workers) if workers == 1 or len(item_list) < 2: return self._submit_direct(item_list, options) return self._submit_pool(item_list, workers, options)
@staticmethod def _submit_direct(item_list, options): """Execute a small job set in the controller process.""" if options.initializer is not None: options.initializer(*options.initargs) results = [] failures = [] for job_spec in item_list: try: results.append( JobResult(job_spec.job_id, job_spec.index, execute_job_spec(job_spec)) ) except Exception as exc: # pylint: disable=broad-exception-caught failures.append(f"{job_spec.job_id} (index {job_spec.index}): {exc}") if failures: raise BackendExecutionError("Local job failure(s): " + "; ".join(failures)) return SubmissionHandle( backend="local", work_dir=Path(options.work_dir or Path.cwd()), job_ids=tuple(job.job_id for job in item_list), metadata={"direct_results": results}, ) @staticmethod def _submit_pool(item_list, workers, options): """Submit a job set to a local process pool.""" start_method = options.backend_config.get("mp_start_method", "fork") context = get_context(str(start_method)) if start_method else None executor_kwargs = { "max_workers": int(workers), "initializer": options.initializer, "initargs": tuple(options.initargs), } if context is not None: executor_kwargs["mp_context"] = context executor = ProcessPoolExecutor(**executor_kwargs) futures = {executor.submit(execute_job_spec, job_spec): job_spec for job_spec in item_list} return SubmissionHandle( backend="local", work_dir=Path(options.work_dir or Path.cwd()), job_ids=tuple(job.job_id for job in item_list), metadata={"executor": executor, "futures": futures}, ) @staticmethod def _validate_config(config): """Validate local-backend-specific configuration.""" unknown = set(config) - _CONFIG_KEYS if unknown: names = ", ".join(sorted(unknown)) raise BackendConfigurationError(f"Unknown local backend configuration key(s): {names}.") start_method = config.get("mp_start_method") if start_method is not None: available_methods = get_all_start_methods() if not isinstance(start_method, str) or start_method not in available_methods: available = ", ".join(available_methods) raise BackendConfigurationError( f"Invalid mp_start_method {start_method!r}. Available methods: {available}." ) schema.validate_dict_using_schema( config, schema_file=SCIENCE_TEST_SCHEMA_PATH / "local_backend.schema.yml", offline=True, )
[docs] def wait(self, submission): """Collect local results in input order.""" if "direct_results" in submission.metadata: return sorted(submission.metadata["direct_results"], key=lambda result: result.index) executor = submission.metadata["executor"] futures = submission.metadata["futures"] results = {} failures = [] try: for future in as_completed(futures): job_spec = futures[future] try: results[job_spec.index] = JobResult( job_id=job_spec.job_id, index=job_spec.index, value=future.result(), ) except Exception as exc: # pylint: disable=broad-exception-caught failures.append(f"{job_spec.job_id} (index {job_spec.index}): {exc}") finally: executor.shutdown(wait=True) if failures: raise BackendExecutionError("Local job failure(s): " + "; ".join(failures)) return [results[index] for index in sorted(results)]
[docs] def is_finished(self, submission): """Return whether direct local results are already available.""" return "direct_results" in submission.metadata
[docs] def collect(self, submission): """Return direct results after local execution.""" return self.wait(submission)
[docs] def cancel(self, submission): """Cancel pending local jobs.""" if "direct_results" in submission.metadata: return for future in submission.metadata["futures"]: future.cancel() submission.metadata["executor"].shutdown(wait=False, cancel_futures=True)