import asyncio import concurrent.futures import logging import pprint import time import traceback from collections import defaultdict from concurrent.futures import ThreadPoolExecutor from copy import deepcopy from dataclasses import dataclass, field from typing import Callable, Dict, List, Optional, Tuple, Union import numpy as np import ray import ray._common.test_utils as test_utils from ray._private.gcs_utils import GcsChannel from ray._raylet import GcsClient from ray.actor import ActorHandle from ray.dashboard.state_aggregator import ( StateAPIManager, ) from ray.util.state import list_tasks, list_workers from ray.util.state.common import ( DEFAULT_LIMIT, DEFAULT_RPC_TIMEOUT, ListApiOptions, PredicateType, SupportedFilterType, ) from ray.util.state.state_manager import StateDataSourceClient import psutil @dataclass class StateAPIMetric: latency_sec: float result_size: int @dataclass class StateAPICallSpec: api: Callable verify_cb: Callable kwargs: Dict = field(default_factory=dict) @dataclass class StateAPIStats: pending_calls: int = 0 total_calls: int = 0 calls: Dict = field(default_factory=lambda: defaultdict(list)) GLOBAL_STATE_STATS = StateAPIStats() STATE_LIST_LIMIT = int(1e6) # 1m STATE_LIST_TIMEOUT = 600 # 10min def invoke_state_api( verify_cb: Callable, state_api_fn: Callable, state_stats: StateAPIStats = GLOBAL_STATE_STATS, key_suffix: Optional[str] = None, print_result: Optional[bool] = False, err_msg: Optional[str] = None, **kwargs, ): """Invoke a State API Args: verify_cb: Callback that takes in the response from `state_api_fn` and returns a boolean, indicating the correctness of the results. state_api_fn: Function of the state API. state_stats: Stats container that tracks latency and call counts. key_suffix: Optional suffix appended to the stats key for this call. print_result: If True, pretty-print the API result. err_msg: Optional message included in the assertion error if the verify callback fails. **kwargs: Keyword arguments to be forwarded to the `state_api_fn`. Returns: The response from ``state_api_fn``. """ if "timeout" not in kwargs: kwargs["timeout"] = STATE_LIST_TIMEOUT # Suppress missing output warning kwargs["raise_on_missing_output"] = False res = None try: state_stats.total_calls += 1 state_stats.pending_calls += 1 t_start = time.perf_counter() res = state_api_fn(**kwargs) t_end = time.perf_counter() if print_result: pprint.pprint(res) metric = StateAPIMetric(t_end - t_start, len(res)) if key_suffix: key = f"{state_api_fn.__name__}_{key_suffix}" else: key = state_api_fn.__name__ state_stats.calls[key].append(metric) assert verify_cb( res ), f"Calling State API failed. len(res)=({len(res)}): {err_msg}" except Exception as e: traceback.print_exc() assert ( False ), f"Calling {state_api_fn.__name__}({kwargs}) failed with {repr(e)}." finally: state_stats.pending_calls -= 1 return res def invoke_state_api_n(*args, **kwargs): def verify(): NUM_API_CALL_SAMPLES = 10 for _ in range(NUM_API_CALL_SAMPLES): invoke_state_api(*args, **kwargs) return True test_utils.wait_for_condition(verify, retry_interval_ms=2000, timeout=30) def aggregate_perf_results(state_stats: StateAPIStats = GLOBAL_STATE_STATS): """Aggregate stats of state API calls Return: This returns a dict of below fields: - max_{api_key_name}_latency_sec: Max latency of call to {api_key_name} - {api_key_name}_result_size_with_max_latency: The size of the result (or the number of bytes for get_log API) for the max latency invocation - avg/p99/p95/p50_{api_key_name}_latency_sec: The percentile latency stats - avg_state_api_latency_sec: The average latency of all the state apis tracked """ # Prevent iteration when modifying error state_stats = deepcopy(state_stats) perf_result = {} for api_key_name, metrics in state_stats.calls.items(): # Per api aggregation # Max latency latency_key = f"max_{api_key_name}_latency_sec" size_key = f"{api_key_name}_result_size_with_max_latency" metric = max(metrics, key=lambda metric: metric.latency_sec) perf_result[latency_key] = metric.latency_sec perf_result[size_key] = metric.result_size latency_list = np.array([metric.latency_sec for metric in metrics]) # avg latency key = f"avg_{api_key_name}_latency_sec" perf_result[key] = np.average(latency_list) # p99 latency key = f"p99_{api_key_name}_latency_sec" perf_result[key] = np.percentile(latency_list, 99) # p95 latency key = f"p95_{api_key_name}_latency_sec" perf_result[key] = np.percentile(latency_list, 95) # p50 latency key = f"p50_{api_key_name}_latency_sec" perf_result[key] = np.percentile(latency_list, 50) all_state_api_latency = sum( metric.latency_sec for metric_samples in state_stats.calls.values() for metric in metric_samples ) perf_result["avg_state_api_latency_sec"] = ( (all_state_api_latency / state_stats.total_calls) if state_stats.total_calls != 0 else -1 ) return perf_result @ray.remote(num_cpus=0) class StateAPIGeneratorActor: def __init__( self, apis: List[StateAPICallSpec], call_interval_s: float = 5.0, print_interval_s: float = 20.0, wait_after_stop: bool = True, print_result: bool = False, ) -> None: """An actor that periodically issues state API Args: apis: List of StateAPICallSpec. call_interval_s: State apis in the `apis` will be issued every `call_interval_s` seconds. print_interval_s: How frequent state api stats will be dumped. wait_after_stop: When true, call to `ray.get(actor.stop.remote())` will wait for all pending state APIs to return. Setting it to `False` might miss some long-running state apis calls. print_result: True if result of each API call is printed. Default False. """ # Configs self._apis = apis self._call_interval_s = call_interval_s self._print_interval_s = print_interval_s self._wait_after_cancel = wait_after_stop self._logger = logging.getLogger(self.__class__.__name__) self._print_result = print_result # States self._tasks = None self._fut_queue = None self._executor = None self._loop = None self._stopping = False self._stopped = False self._stats = StateAPIStats() async def start(self): # Run the periodic api generator self._fut_queue = asyncio.Queue() self._executor = concurrent.futures.ThreadPoolExecutor() self._tasks = [ asyncio.ensure_future(awt) for awt in [ self._run_generator(), self._run_result_waiter(), self._run_stats_reporter(), ] ] await asyncio.gather(*self._tasks) def call(self, fn, verify_cb, **kwargs): def run_fn(): try: self._logger.debug(f"calling {fn.__name__}({kwargs})") return invoke_state_api( verify_cb, fn, state_stats=self._stats, print_result=self._print_result, **kwargs, ) except Exception as e: self._logger.warning(f"{fn.__name__}({kwargs}) failed with: {repr(e)}") return None fut = asyncio.get_running_loop().run_in_executor(self._executor, run_fn) return fut async def _run_stats_reporter(self): while not self._stopped: # Keep the reporter running until all pending apis finish and the bool # `self._stopped` is then True self._logger.info(pprint.pprint(aggregate_perf_results(self._stats))) try: await asyncio.sleep(self._print_interval_s) except asyncio.CancelledError: self._logger.info( "_run_stats_reporter cancelled, " f"waiting for all api {self._stats.pending_calls}calls to return..." ) async def _run_generator(self): try: while not self._stopping: # Run the state API in another thread for api_spec in self._apis: fut = self.call(api_spec.api, api_spec.verify_cb, **api_spec.kwargs) self._fut_queue.put_nowait(fut) await asyncio.sleep(self._call_interval_s) except asyncio.CancelledError: # Stop running self._logger.info("_run_generator cancelled, now stopping...") return async def _run_result_waiter(self): try: while not self._stopping: fut = await self._fut_queue.get() await fut except asyncio.CancelledError: self._logger.info( f"_run_result_waiter cancelled, cancelling {self._fut_queue.qsize()} " "pending futures..." ) while not self._fut_queue.empty(): fut = self._fut_queue.get_nowait() if self._wait_after_cancel: await fut else: # Ignore the queue futures if we are not # waiting on them after stop() called fut.cancel() return def get_stats(self): # deep copy to prevent race between reporting and modifying stats return aggregate_perf_results(self._stats) def ready(self): pass def stop(self): self._stopping = True self._logger.debug(f"calling stop, canceling {len(self._tasks)} tasks") for task in self._tasks: task.cancel() # This will block the stop() function until all futures are cancelled # if _wait_after_cancel=True. When _wait_after_cancel=False, it will still # wait for any in-progress futures. # See: https://docs.python.org/3.8/library/concurrent.futures.html self._executor.shutdown(wait=self._wait_after_cancel) self._stopped = True def periodic_invoke_state_apis_with_actor(*args, **kwargs) -> ActorHandle: current_node_ip = ray._private.worker.global_worker.node_ip_address # Schedule the actor on the current node. actor = StateAPIGeneratorActor.options( resources={f"node:{current_node_ip}": 0.001} ).remote(*args, **kwargs) print("Waiting for state api actor to be ready...") ray.get(actor.ready.remote()) print("State api actor is ready now.") actor.start.remote() return actor def get_state_api_manager(gcs_address: str) -> StateAPIManager: gcs_client = GcsClient(address=gcs_address) gcs_channel = GcsChannel(gcs_address=gcs_address, aio=True) gcs_channel.connect() state_api_data_source_client = StateDataSourceClient( gcs_channel.channel(), gcs_client ) return StateAPIManager( state_api_data_source_client, thread_pool_executor=ThreadPoolExecutor( thread_name_prefix="state_api_test_utils" ), ) def summarize_worker_startup_time(): workers = list_workers( detail=True, filters=[("worker_type", "=", "WORKER")], limit=10000, raise_on_missing_output=False, ) time_to_launch = [] time_to_initialize = [] for worker in workers: launch_time = worker.get("worker_launch_time_ms") launched_time = worker.get("worker_launched_time_ms") start_time = worker.get("start_time_ms") if launched_time > 0: time_to_launch.append(launched_time - launch_time) if start_time: time_to_initialize.append(start_time - launched_time) time_to_launch.sort() time_to_initialize.sort() def print_latencies(latencies): print(f"Avg: {round(sum(latencies) / len(latencies), 2)} ms") print(f"P25: {round(latencies[int(len(latencies) * 0.25)], 2)} ms") print(f"P50: {round(latencies[int(len(latencies) * 0.5)], 2)} ms") print(f"P95: {round(latencies[int(len(latencies) * 0.95)], 2)} ms") print(f"P99: {round(latencies[int(len(latencies) * 0.99)], 2)} ms") print("Time to launch workers") print_latencies(time_to_launch) print("=======================") print("Time to initialize workers") print_latencies(time_to_initialize) def verify_failed_task( name: str, error_type: str, error_message: Union[str, List[str], None] = None ) -> bool: """ Check if a task with 'name' has failed with the exact error type 'error_type' and 'error_message' in the error message. """ tasks = list_tasks(filters=[("name", "=", name)], detail=True) assert len(tasks) == 1, tasks t = tasks[0] assert t["state"] == "FAILED", t assert t["error_type"] == error_type, t if error_message is not None: if isinstance(error_message, str): error_message = [error_message] for msg in error_message: assert msg in t.get("error_message", None), t return True def wait_for_task_states(name_to_state: Dict[str, str], timeout: float = 30) -> None: """ Block until every task in ``name_to_state`` is observed in its expected state via the State API, or raise if the timeout expires. """ def _check(): for name, state in name_to_state.items(): tasks = list_tasks(filters=[("name", "=", name), ("state", "=", state)]) assert len(tasks) == 1, f"{name} not in {state}" return True test_utils.wait_for_condition(_check, timeout=timeout) def _is_actor_task_running(actor_pid: int, task_name: str): """ Check whether the actor task `task_name` is running on the actor process with pid `actor_pid`. Args: actor_pid: The pid of the actor process. task_name: The name of the actor task. Returns: True if the actor task is running, False otherwise. Limitation: If the actor task name is set using options.name and is a substring of the actor name, this function may return true even if the task is not running on the actor process. To resolve this issue, we can possibly pass in the actor name. """ if not psutil.pid_exists(actor_pid): return False """ Why use both `psutil.Process.name()` and `psutil.Process.cmdline()`? 1. Core worker processes call `setproctitle` to set the process title before and after executing tasks. However, the definition of "title" is a bit complex. [ref]: https://github.com/dvarrazzo/py-setproctitle > The process title is usually visible in files such as /proc/PID/cmdline, /proc/PID/status, /proc/PID/comm, depending on the operating system and kernel version. This information is used by user-space tools such as ps and top. Ideally, we would only need to check `psutil.Process.cmdline()`, but I decided to check both `psutil.Process.name()` and `psutil.Process.cmdline()` based on the definition of "title" stated above. 2. Additionally, the definition of `psutil.Process.name()` is not consistent with the definition of "title" in `setproctitle`. The length of `/proc/PID/comm` and the prefix of `/proc/PID/cmdline` affect the return value of `psutil.Process.name()`. In addition, executing `setproctitle` in different threads within the same process may result in different outcomes. To learn more details, please refer to the source code of `psutil`: [ref]: https://github.com/giampaolo/psutil/blob/a17550784b0d3175da01cdb02cee1bc6b61637dc/psutil/__init__.py#L664-L693 3. `/proc/PID/comm` will be truncated to TASK_COMM_LEN (16) characters (including the terminating null byte). [ref]: https://man7.org/linux/man-pages/man5/proc_pid_comm.5.html """ name = psutil.Process(actor_pid).name() if task_name in name and name.startswith("ray::"): return True cmdline = psutil.Process(actor_pid).cmdline() # If `options.name` is set, the format is `ray::`. If not, # the format is `ray::.`. if cmdline and task_name in cmdline[0] and cmdline[0].startswith("ray::"): return True return False def verify_schema(state, result_dict: dict, detail: bool = False): """ Verify the schema of the result_dict is the same as the state. """ state_fields_columns = set() if detail: state_fields_columns = state.columns() else: state_fields_columns = state.base_columns() for k in state_fields_columns: assert k in result_dict for k in result_dict: assert k in state_fields_columns # Make the field values can be converted without error as well state(**result_dict) def create_api_options( timeout: int = DEFAULT_RPC_TIMEOUT, limit: int = DEFAULT_LIMIT, filters: List[Tuple[str, PredicateType, SupportedFilterType]] = None, detail: bool = False, exclude_driver: bool = True, ): if not filters: filters = [] return ListApiOptions( limit=limit, timeout=timeout, filters=filters, server_timeout_multiplier=1.0, detail=detail, exclude_driver=exclude_driver, )