# 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. # ruff: noqa: F401 """RPC server implementation. Note ---- Server is TCP based with the following protocol: - Initial handshake to the peer - [RPC_MAGIC, keysize(int32), key-bytes] - The key is in format - {server|client}:device-type[:random-key] [-timeout=timeout] """ # pylint: disable=invalid-name import ctypes import errno import logging import multiprocessing import os import select import socket import struct import sys import threading import time from pathlib import Path import tvm_ffi from tvm.runtime.module import load_module as _load_module from tvm.support import utils from tvm.support.popen_pool import PopenWorker # pylint: disable=unused-import from . import _ffi_api, base, testing from .base import TrackerCode logger = logging.getLogger("RPCServer") console_handler = logging.StreamHandler() console_handler.setFormatter( logging.Formatter( fmt="%(asctime)s.%(msecs)03d %(levelname)s %(message)s", datefmt="%Y-%m-%d %H:%M:%S" ) ) logger.addHandler(console_handler) logger.setLevel(logging.INFO) logger.propagate = False def _server_env(load_library, work_path=None): """Server environment function return temp dir""" if work_path: temp = work_path else: temp = utils.tempdir() # pylint: disable=unused-variable @tvm_ffi.register_global_func("tvm.rpc.server.workpath", override=True) def get_workpath(path): return temp.relpath(path) @tvm_ffi.register_global_func("tvm.rpc.server.load_module", override=True) def load_module(file_name): """Load module from remote side.""" path = temp.relpath(file_name) m = _load_module(path) logger.info("load_module %s", path) return m @tvm_ffi.register_global_func("tvm.rpc.server.download_linked_module", override=True) def download_linked_module(file_name): """Load module from remote side.""" # pylint: disable=import-outside-toplevel path = temp.relpath(file_name) if path.endswith(".o"): # Extra dependencies during runtime. from tvm.support import cc as _cc _cc.create_shared(path + ".so", path) path += ".so" elif path.endswith(".tar"): # Extra dependencies during runtime. from tvm.support import cc as _cc from tvm.support import tar as _tar tar_temp = utils.tempdir(custom_path=path.replace(".tar", "")) _tar.untar(path, tar_temp.temp_dir) files = [tar_temp.relpath(x) for x in tar_temp.listdir()] _cc.create_shared(path + ".so", files) path += ".so" elif path.endswith(".dylib") or path.endswith(".so"): pass else: raise RuntimeError(f"Do not know how to link {file_name}") logger.info("Send linked module %s to client", path) return bytearray(open(path, "rb").read()) libs = [] load_library = load_library.split(":") if load_library else [] for file_name in load_library: # Resolve the literal library file name against the current working # directory, preserving the exact filename the caller requested. resolved = tvm_ffi.libinfo._resolve_and_validate( [Path.cwd() / file_name], cond=lambda p: p.is_file() ) if resolved is None: raise RuntimeError(f"Cannot find library {file_name}") file_name = resolved libs.append(ctypes.CDLL(file_name, ctypes.RTLD_GLOBAL)) logger.info("Load additional library %s", file_name) temp.libs = libs return temp def _serve_loop(sock, load_library, work_path): _server_env(load_library, work_path) _ffi_api.ServerLoop(sock.fileno()) def _parse_server_opt(opts): # parse client options ret = {} for kv in opts: if kv.startswith("-timeout="): ret["timeout"] = float(kv[9:]) return ret def _serving(sock, addr, opts, load_library): logger.info(f"connected from {addr}") work_path = utils.tempdir() old_cwd = os.getcwd() os.chdir(work_path.path) # Avoiding file name conflict between sessions. logger.info(f"start serving at {work_path.path}") server_proc = multiprocessing.Process(target=_serve_loop, args=(sock, load_library, work_path)) server_proc.start() server_proc.join(opts.get("timeout", None)) # Wait until finish or timeout. if server_proc.is_alive(): logger.info("timeout in RPC session, kill..") _ffi_api.ReturnException( sock.fileno(), f"RPCSessionTimeoutError: Your {opts['timeout']}s session has expired, " f'try to increase the "session_timeout" value.', ) try: import psutil # pylint: disable=import-outside-toplevel # Terminate worker children firstly. for child in psutil.Process(server_proc.pid).children(recursive=True): child.terminate() except ImportError: # Don't dependent `psutil` hardly, because it isn't a pure Python # package and maybe hard to be installed on some platforms. pass server_proc.terminate() elif server_proc.exitcode != 0: raise RuntimeError( f"Child process {server_proc.pid} exited unsuccessfully " f"with error code {server_proc.exitcode}" ) logger.info(f"finish serving {addr}") os.chdir(old_cwd) work_path.remove() sock.close() def _listen_loop(sock, port, rpc_key, tracker_addr, load_library, custom_addr): """Listening loop of the server.""" def _accept_conn(listen_sock, tracker_conn, ping_period=2): """Accept connection from the other places. Parameters ---------- listen_sock: Socket The socket used by listening process. tracker_conn : connection to tracker Tracker connection ping_period : float, optional ping tracker every k seconds if no connection is accepted. """ old_keyset = set() # Report resource to tracker if tracker_conn: matchkey = base.random_key(rpc_key) base.sendjson(tracker_conn, [TrackerCode.PUT, rpc_key, (port, matchkey), custom_addr]) assert base.recvjson(tracker_conn) == TrackerCode.SUCCESS else: matchkey = rpc_key unmatch_period_count = 0 unmatch_timeout = 4 # Wait until we get a valid connection while True: if tracker_conn: trigger = select.select([listen_sock], [], [], ping_period) if listen_sock not in trigger[0]: base.sendjson(tracker_conn, [TrackerCode.GET_PENDING_MATCHKEYS]) pending_keys = base.recvjson(tracker_conn) old_keyset.add(matchkey) # if match key not in pending key set # it means the key is acquired by a client but not used. if matchkey not in pending_keys: unmatch_period_count += 1 else: unmatch_period_count = 0 # regenerate match key if key is acquired but not used for a while if unmatch_period_count * ping_period > unmatch_timeout + ping_period: logger.info("no incoming connections, regenerate key ...") matchkey = base.random_key(rpc_key, cmap=old_keyset) base.sendjson( tracker_conn, [TrackerCode.PUT, rpc_key, (port, matchkey), custom_addr] ) assert base.recvjson(tracker_conn) == TrackerCode.SUCCESS unmatch_period_count = 0 continue conn, addr = listen_sock.accept() magic = struct.unpack(" max_retry: raise RuntimeError(f"Maximum retry error: last error: {err!s}") time.sleep(retry_period) class PopenRPCServerState: """Internal PopenRPCServer State""" current = None def __init__( self, host, port=9091, port_end=9199, is_proxy=False, tracker_addr=None, key="", load_library=None, custom_addr=None, silent=False, reuse_addr=True, timeout=None, ): # start update self.host = host self.port = port self.libs = [] self.custom_addr = custom_addr if silent: logger.setLevel(logging.ERROR) if not is_proxy: sock = socket.socket(base.get_addr_family((host, port)), socket.SOCK_STREAM) # Never set socket SO_REUSEADDR on Windows. The SO_REUSEADDR flag allow reusing the # inactivate TIME_WATI state sockets on POSIX, but on Windows it will allow two or more # activate sockets to bind on the same address and port if they all set SO_REUSEADDR, # and result in indeterminate behavior. if reuse_addr and sys.platform != "win32": sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) if timeout is not None: sock.settimeout(timeout) self.port = None for my_port in range(port, port_end): try: sock.bind((host, my_port)) self.port = my_port break except OSError as sock_err: if sock_err.errno in [errno.EADDRINUSE]: continue raise sock_err if not self.port: raise ValueError(f"cannot bind to any port in [{port}, {port_end})") logger.info("bind to %s:%d", host, self.port) sock.listen(1) self.sock = sock self.thread = threading.Thread( target=_listen_loop, args=(self.sock, self.port, key, tracker_addr, load_library, self.custom_addr), ) self.thread.start() else: self.thread = threading.Thread( target=_connect_proxy_loop, args=((host, port), key, load_library) ) self.thread.start() def _popen_start_rpc_server( host, port=9091, port_end=9199, is_proxy=False, tracker_addr=None, key="", load_library=None, custom_addr=None, silent=False, no_fork=False, server_init_callback=None, reuse_addr=True, timeout=None, ): if no_fork: multiprocessing.set_start_method("spawn") if server_init_callback: server_init_callback() # This is a function that will be sent to the # Popen worker to run on a separate process. # Create and start the server in a different thread state = PopenRPCServerState( host, port, port_end, is_proxy, tracker_addr, key, load_library, custom_addr, silent, reuse_addr, timeout, ) PopenRPCServerState.current = state # returns the port so that the main can get the port number. return state.port class Server: """Start RPC server on a separate process. This is a simple python implementation based on multi-processing. It is also possible to implement a similar C based server with TVM runtime which does not depend on the python. Parameters ---------- host : str The host url of the server. port : int The port to be bind to port_end : int, optional The end port to search is_proxy : bool, optional Whether the address specified is a proxy. If this is true, the host and port actually corresponds to the address of the proxy server. tracker_addr: Tuple (str, int) , optional The address of RPC Tracker in tuple(host, ip) format. If is not None, the server will register itself to the tracker. key : str, optional The key used to identify the device type in tracker. load_library : str, optional List of additional libraries to be loaded during execution. custom_addr: str, optional Custom IP Address to Report to RPC Tracker silent: bool, optional Whether run this server in silent mode. no_fork: bool, optional Whether forbid fork in multiprocessing. server_init_callback: Callable, optional Additional initialization function when starting the server. reuse_addr: bool, optional Allows the kernel to reuse a local socket in TIME_WAIT state. timeout: float, optional set a timeout for all operations on the socket Note ---- TVM RPC server assumes that the user is trusted and needs to be used in a trusted network environment and encrypted channels. It allows writings of arbitrary files into the server and provide full remote code execution capabilities to anyone who can access this API. The RPC server only sees functions in the tvm namespace. To bring additional custom functions to the server env, you can use server_init_callback. .. code:: python def server_init_callback(): import tvm # must import mypackage here import mypackage tvm.register_global_func("function", mypackage.func) server = rpc.Server(host, server_init_callback=server_init_callback) """ def __init__( self, host="0.0.0.0", port=9091, port_end=9199, is_proxy=False, tracker_addr=None, key="", load_library=None, custom_addr=None, silent=False, no_fork=False, server_init_callback=None, reuse_addr=True, timeout=None, ): try: if _ffi_api.ServerLoop is None: raise RuntimeError("Please compile with USE_RPC=1") except NameError: raise RuntimeError("Please compile with USE_RPC=1") self.proc = PopenWorker() # send the function self.proc.send( _popen_start_rpc_server, [ host, port, port_end, is_proxy, tracker_addr, key, load_library, custom_addr, silent, no_fork, server_init_callback, reuse_addr, timeout, ], ) # receive the port self.port = self.proc.recv() self.host = host def terminate(self): """Terminate the server process""" if self.proc: self.proc.kill() self.proc = None def __del__(self): try: self.terminate() except ImportError: pass