# Licensed to the Apache Software Foundation (ASF) under one # or more contributor license agreements. See the NOTICE file # distributed with this work for additional information # regarding copyright ownership. The ASF licenses this file # to you under the Apache License, Version 2.0 (the # "License"); you may not use this file except in compliance # with the License. You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, # software distributed under the License is distributed on an # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY # KIND, either express or implied. See the License for the # specific language governing permissions and limitations # under the License. # pylint: disable=invalid-name # ruff: noqa: E731 """Multiprocessing via Popen. This module provides a multi-processing pool backed by Popen. with additional timeout support. """ import concurrent.futures import os import pickle import struct import subprocess import sys import threading from collections import namedtuple from enum import IntEnum def kill_child_processes(pid): """Kill all child processes recursively for a given pid. Parameters ---------- pid : int The given parameter id. """ # pylint: disable=import-outside-toplevel import psutil try: parent = psutil.Process(pid) children = parent.children(recursive=True) except psutil.NoSuchProcess: return for process in children: try: process.kill() except psutil.NoSuchProcess: pass class StatusKind(IntEnum): """Running and return value status.""" RUNNING = 0 COMPLETE = 1 EXCEPTION = 2 TIMEOUT = 3 class MapResult(namedtuple("MapResult", ["status", "value"])): """Result of map_with_error_catching. Parameters ---------- status : StatusKind The status of the result. value : Any The result value. """ __slots__ = [] class PopenWorker: """A subprocess worker via Popen. PopenWorker provides a low-level API to interact with a separate process via Popen. Parameters ---------- initializer: callable or None A callable initializer, or None initargs: Tuple[object] A tuple of args for the initializer maximum_uses: Optional[int] The maximum number of times a process can be used before being recycled, i.e. killed and restarted. If `None`, the process will be reused until an operation times out. stdout: Union[None, int, IO[Any]] The standard output streams handler specified for the popen process. stderr: Union[None, int, IO[Any]] The standard error streams handler specified for the popen process. """ def __init__(self, initializer=None, initargs=(), maximum_uses=None, stdout=None, stderr=None): self._proc = None self._initializer = initializer self._initargs = initargs self._maximum_uses = maximum_uses self._remaining_uses = None self._stdout = stdout self._stderr = stderr if self._initializer is not None and not callable(self._initializer): raise TypeError("initializer must be callable for PopenWorker") def __del__(self): try: self.kill() except ImportError: pass def kill(self): """Kill the current running process and cleanup. Note ---- The worker can start a new process when send is called again. """ if self._proc is not None: # allow gracefully shutdown try: self._writer.close() except OSError: pass try: self._reader.close() except OSError: pass # kill all child processes recursively try: kill_child_processes(self._proc.pid) except TypeError: pass try: self._proc.kill() except OSError: pass # Join the child process to avoid zombie processes self.join(timeout=1.0) self._proc = None self._remaining_uses = None def _start(self): """Start a new subprocess if nothing is available""" if self._proc is not None: return # connect subprocess with a pair of pipes main_read, worker_write = os.pipe() worker_read, main_write = os.pipe() cmd = [sys.executable, "-m", "tvm.exec.popen_worker"] if sys.platform == "win32": # pylint: disable=import-outside-toplevel import msvcrt worker_read_handle = msvcrt.get_osfhandle(worker_read) worker_write_handle = msvcrt.get_osfhandle(worker_write) os.set_handle_inheritable(worker_read_handle, True) os.set_handle_inheritable(worker_write_handle, True) cmd += [str(worker_read_handle), str(worker_write_handle)] self._proc = subprocess.Popen( cmd, close_fds=False, stdout=self._stdout, stderr=self._stderr ) else: cmd += [str(worker_read), str(worker_write)] self._proc = subprocess.Popen( cmd, pass_fds=(worker_read, worker_write), stdout=self._stdout, stderr=self._stderr ) # close worker side of the pipe os.close(worker_read) os.close(worker_write) self._reader = os.fdopen(main_read, "rb") self._writer = os.fdopen(main_write, "wb") def join(self, timeout=None): """Join the current process worker before it terminates. Parameters ---------- timeout: Optional[number] Timeout value, block at most timeout seconds if it is a positive number. """ if self._proc: try: self._proc.wait(timeout) except subprocess.TimeoutExpired: pass def is_alive(self): """Check if the process is alive""" if self._proc: return self._proc.poll() is None return False def send(self, fn, args=(), kwargs=None, timeout=None): """Send a new function task ``fn(*args, **kwargs)`` to the subprocess. Parameters ---------- fn : function The function to be invoked. args : list Positional argument. kwargs : dict Keyword arguments timeout : float Timeout value when executing the function Note ---- The caller must call recv before calling the next send in order to make sure the timeout and child process exit won't affect the later requests. """ # use cloud pickle # pylint: disable=import-outside-toplevel import cloudpickle if self._proc is not None and self._maximum_uses and self._remaining_uses == 0: # Time to recycle the process. self.kill() if self._proc is None: self._start() # init if self._initializer is not None: self.send(self._initializer, self._initargs) self.recv() # N.B. The initializer doesn't count as a "use" self._remaining_uses = self._maximum_uses kwargs = {} if not kwargs else kwargs data = cloudpickle.dumps((fn, args, kwargs, timeout), protocol=pickle.HIGHEST_PROTOCOL) try: self._writer.write(struct.pack(" MapResult: # pylint: disable=broad-except try: return MapResult(status=StatusKind.COMPLETE, value=self._worker_run(fn, args, kwargs)) except TimeoutError as exception: return MapResult(status=StatusKind.TIMEOUT, value=exception) except Exception as exception: return MapResult(status=StatusKind.EXCEPTION, value=exception) def submit(self, fn, *args, **kwargs) -> concurrent.futures.Future: """Submit a new function job to the pool Parameters ---------- fn : function The function to be invoked. args : list Positional argument. kwargs : dict Keyword arguments Returns ------- future : concurrent.futures.Future A future that can be used to access the result. """ # pylint: disable=unnecessary-lambda worker = lambda *args: self._worker_run(*args) return self._threadpool.submit(worker, fn, args, kwargs) def map_with_error_catching(self, fn, iterator): """Same as map, but catches exceptions and return them instead. Parameters ---------- fn : function The function to be invoked. iterator : Iterator Input iterator. Returns ------- out_iter : Iterator[MapResult] The result iterator. """ worker = lambda x: self._worker_run_with_error_catching(fn, (x,), None) return self._threadpool.map(worker, iterator)