chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
from ray.data._internal.issue_detection.detectors.hanging_detector import (
|
||||
HangingExecutionIssueDetector,
|
||||
HangingExecutionIssueDetectorConfig,
|
||||
)
|
||||
from ray.data._internal.issue_detection.issue_detector import Issue, IssueDetector
|
||||
from ray.data._internal.issue_detection.issue_detector_configuration import (
|
||||
IssueDetectorsConfiguration,
|
||||
)
|
||||
from ray.data._internal.issue_detection.issue_detector_manager import (
|
||||
IssueDetectorManager,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"Issue",
|
||||
"IssueDetector",
|
||||
"IssueDetectorManager",
|
||||
"IssueDetectorsConfiguration",
|
||||
"HangingExecutionIssueDetector",
|
||||
"HangingExecutionIssueDetectorConfig",
|
||||
]
|
||||
@@ -0,0 +1,21 @@
|
||||
from ray.data._internal.issue_detection.detectors.hanging_detector import (
|
||||
HangingExecutionIssueDetector,
|
||||
HangingExecutionIssueDetectorConfig,
|
||||
)
|
||||
from ray.data._internal.issue_detection.detectors.hash_shuffle_detector import (
|
||||
HashShuffleAggregatorIssueDetector,
|
||||
HashShuffleAggregatorIssueDetectorConfig,
|
||||
)
|
||||
from ray.data._internal.issue_detection.detectors.high_memory_detector import (
|
||||
HighMemoryIssueDetector,
|
||||
HighMemoryIssueDetectorConfig,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"HangingExecutionIssueDetector",
|
||||
"HangingExecutionIssueDetectorConfig",
|
||||
"HashShuffleAggregatorIssueDetector",
|
||||
"HashShuffleAggregatorIssueDetectorConfig",
|
||||
"HighMemoryIssueDetector",
|
||||
"HighMemoryIssueDetectorConfig",
|
||||
]
|
||||
@@ -0,0 +1,282 @@
|
||||
import logging
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import TYPE_CHECKING, DefaultDict, Dict, List, Optional, Union
|
||||
|
||||
import requests
|
||||
|
||||
import ray
|
||||
from ray.data._internal.execution.interfaces.op_runtime_metrics import RunningTaskInfo
|
||||
from ray.data._internal.issue_detection.issue_detector import (
|
||||
Issue,
|
||||
IssueDetector,
|
||||
IssueType,
|
||||
)
|
||||
from ray.util.state import get_task
|
||||
from ray.util.state.common import TaskState
|
||||
from ray.util.state.exception import RayStateApiException
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ray.data._internal.execution.interfaces.physical_operator import (
|
||||
PhysicalOperator,
|
||||
)
|
||||
from ray.data._internal.execution.streaming_executor import StreamingExecutor
|
||||
|
||||
# Default minimum count of tasks before using adaptive thresholds
|
||||
DEFAULT_OP_TASK_STATS_MIN_COUNT = 10
|
||||
# Default multiple of standard deviations to use as hanging threshold
|
||||
DEFAULT_OP_TASK_STATS_STD_FACTOR = 10
|
||||
# Default detection time interval.
|
||||
DEFAULT_DETECTION_TIME_INTERVAL_S = 30.0
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
OpId = str
|
||||
TaskIdx = int
|
||||
# Map of operator id -> task index -> hanging execution state.
|
||||
HangingOpTasks = DefaultDict[OpId, Dict[TaskIdx, "HangingExecutionState"]]
|
||||
|
||||
|
||||
def _format_timestamp(epoch: float) -> str:
|
||||
"""Format a ``time.time()`` epoch value as a human-readable UTC string."""
|
||||
return datetime.fromtimestamp(epoch, tz=timezone.utc).strftime(
|
||||
"%Y-%m-%d %H:%M:%S %Z"
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class TaskMetadata:
|
||||
"""Subset of TaskState fields relevant for hanging detection."""
|
||||
|
||||
attempt_number: int
|
||||
node_id: str
|
||||
pid: int
|
||||
|
||||
@classmethod
|
||||
def from_task_state(cls, task_state: TaskState) -> "TaskMetadata":
|
||||
return cls(
|
||||
attempt_number=task_state.attempt_number,
|
||||
node_id=task_state.node_id,
|
||||
pid=task_state.worker_pid,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class HangingExecutionState:
|
||||
operator_id: OpId
|
||||
task_idx: TaskIdx
|
||||
task_id: ray.TaskID
|
||||
task_metadata: Optional[TaskMetadata]
|
||||
bytes_output: int
|
||||
# NOTE This is from perf_couinter()
|
||||
start_time_hanging: float
|
||||
|
||||
def hanging_time(self):
|
||||
return time.perf_counter() - self.start_time_hanging
|
||||
|
||||
|
||||
@dataclass
|
||||
class HangingExecutionIssueDetectorConfig:
|
||||
op_task_stats_min_count: int = field(default=DEFAULT_OP_TASK_STATS_MIN_COUNT)
|
||||
op_task_stats_std_factor: float = field(default=DEFAULT_OP_TASK_STATS_STD_FACTOR)
|
||||
detection_time_interval_s: float = DEFAULT_DETECTION_TIME_INTERVAL_S
|
||||
|
||||
|
||||
class HangingExecutionIssueDetector(IssueDetector):
|
||||
def __init__(
|
||||
self,
|
||||
dataset_id: str,
|
||||
operators: List["PhysicalOperator"],
|
||||
config: HangingExecutionIssueDetectorConfig,
|
||||
):
|
||||
self._dataset_id = dataset_id
|
||||
self._operators = operators
|
||||
self._detector_cfg = config
|
||||
|
||||
self._op_task_stats_min_count = self._detector_cfg.op_task_stats_min_count
|
||||
self._op_task_stats_std_factor_threshold = (
|
||||
self._detector_cfg.op_task_stats_std_factor
|
||||
)
|
||||
|
||||
# Map of operator id to Dict[task index, state]
|
||||
self._hanging_op_tasks: HangingOpTasks = defaultdict(dict)
|
||||
|
||||
@classmethod
|
||||
def from_executor(
|
||||
cls, executor: "StreamingExecutor"
|
||||
) -> "HangingExecutionIssueDetector":
|
||||
"""Factory method to create a HangingExecutionIssueDetector from a StreamingExecutor.
|
||||
|
||||
Args:
|
||||
executor: The StreamingExecutor instance to extract dependencies from.
|
||||
|
||||
Returns:
|
||||
An instance of HangingExecutionIssueDetector.
|
||||
"""
|
||||
operators = list(executor._topology.keys()) if executor._topology else []
|
||||
ctx = executor._data_context
|
||||
return cls(
|
||||
dataset_id=executor._dataset_id,
|
||||
operators=operators,
|
||||
config=ctx.issue_detectors_config.hanging_detector_config,
|
||||
)
|
||||
|
||||
def _create_issue(
|
||||
self,
|
||||
operator: "PhysicalOperator",
|
||||
hanging_execution_state: HangingExecutionState,
|
||||
) -> Issue:
|
||||
|
||||
hes = hanging_execution_state
|
||||
op_task_stats = operator.metrics.op_task_duration_stats
|
||||
avg_duration = op_task_stats.mean
|
||||
stdev = op_task_stats.stddev
|
||||
|
||||
meta = hes.task_metadata
|
||||
task_info = ""
|
||||
if meta is not None:
|
||||
task_info = f"(pid={meta.pid}, node_id={meta.node_id}, attempt={meta.attempt_number}) "
|
||||
|
||||
hanging_time = hes.hanging_time()
|
||||
hanging_since = _format_timestamp(time.time() - hanging_time)
|
||||
|
||||
message = (
|
||||
f"A task (task_id={hes.task_id}) of operator "
|
||||
f"{operator.name} {task_info}has been running or stuck in scheduling for "
|
||||
f"{hanging_time:.2f}s, which is longer than the average task "
|
||||
f"duration + z-score * stddev of this operator "
|
||||
f"({avg_duration:.2f} + "
|
||||
f"{self._op_task_stats_std_factor_threshold} * "
|
||||
f"{stdev:.2f}s). "
|
||||
f"Last time task produced output or made any progress was {hanging_since}. "
|
||||
f"If this message persists, please check "
|
||||
f"the stack trace of the task for potential hanging "
|
||||
f"issues. To adjust the z-score value, set "
|
||||
f"`ray.data.DataContext.get_current()"
|
||||
f".issue_detectors_config.hanging_detector_config"
|
||||
f".op_task_stats_std_factor`."
|
||||
)
|
||||
|
||||
return Issue(
|
||||
dataset_name=self._dataset_id,
|
||||
operator_id=hes.operator_id,
|
||||
issue_type=IssueType.HANGING,
|
||||
message=message,
|
||||
)
|
||||
|
||||
def _refresh_state(
|
||||
self,
|
||||
operator: "PhysicalOperator",
|
||||
task_idx: TaskIdx,
|
||||
old_state: Optional[HangingExecutionState],
|
||||
task_info: RunningTaskInfo,
|
||||
) -> HangingExecutionState:
|
||||
"""Build a HangingExecutionState, fetching task metadata lazily.
|
||||
|
||||
Task metadata (pid, node_id, attempt) is fetched from the Ray
|
||||
State API only when unknown or potentially stale (e.g. after the
|
||||
task made progress then stalled again).
|
||||
"""
|
||||
task_metadata: Optional[TaskMetadata] = None
|
||||
if old_state is not None:
|
||||
task_metadata = old_state.task_metadata
|
||||
else:
|
||||
task_metadata = get_latest_state_for_task(task_info.task_id)
|
||||
|
||||
return HangingExecutionState(
|
||||
operator_id=operator.id,
|
||||
task_idx=task_idx,
|
||||
task_id=task_info.task_id,
|
||||
task_metadata=task_metadata,
|
||||
bytes_output=task_info.bytes_output,
|
||||
start_time_hanging=task_info.last_updated,
|
||||
)
|
||||
|
||||
def detect(self) -> List[Issue]:
|
||||
|
||||
issues: List[Issue] = []
|
||||
# Build fresh maps each cycle so that tasks which finished or
|
||||
# dropped below the threshold are automatically pruned.
|
||||
hanging_op_tasks: HangingOpTasks = defaultdict(dict)
|
||||
|
||||
for operator in self._operators:
|
||||
if operator.has_execution_finished():
|
||||
continue
|
||||
|
||||
op_metrics = operator.metrics
|
||||
op_task_stats = op_metrics.op_task_duration_stats
|
||||
# 1) Skip if not reached minimum task count
|
||||
if op_task_stats.num_samples < self._op_task_stats_min_count:
|
||||
continue
|
||||
|
||||
# 2) Skip if under threshold of mean + z-score * stddev
|
||||
mean = op_task_stats.mean
|
||||
stddev = op_task_stats.stddev
|
||||
threshold = mean + self._op_task_stats_std_factor_threshold * stddev
|
||||
|
||||
for task_idx, task_info in op_metrics._running_tasks.items():
|
||||
|
||||
time_since_last_update = time.perf_counter() - task_info.last_updated
|
||||
if time_since_last_update <= threshold:
|
||||
continue
|
||||
|
||||
old_state = self._hanging_op_tasks[operator.id].get(task_idx)
|
||||
|
||||
new_state = self._refresh_state(
|
||||
operator=operator,
|
||||
task_idx=task_idx,
|
||||
old_state=old_state,
|
||||
task_info=task_info,
|
||||
)
|
||||
|
||||
hanging_op_tasks[operator.id][task_idx] = new_state
|
||||
|
||||
if old_state == new_state:
|
||||
continue
|
||||
|
||||
issues.append(
|
||||
self._create_issue(
|
||||
operator=operator, hanging_execution_state=new_state
|
||||
)
|
||||
)
|
||||
|
||||
self._hanging_op_tasks = hanging_op_tasks
|
||||
return issues
|
||||
|
||||
def detection_time_interval_s(self) -> float:
|
||||
return self._detector_cfg.detection_time_interval_s
|
||||
|
||||
|
||||
def get_latest_state_for_task(task_id: ray.TaskID) -> Optional[TaskMetadata]:
|
||||
"""Query the Ray State API for the latest attempt of a task.
|
||||
|
||||
Returns a TaskMetadata with the highest attempt_number when multiple
|
||||
attempts exist, or None if the task is not found (can happen when
|
||||
get_task() is called shortly after submission, before the state API
|
||||
has indexed it) or the API is unreachable.
|
||||
"""
|
||||
try:
|
||||
# NOTE: timeout is set to 1 because ray will take max(1, timeout).
|
||||
# TODO(Justin): Make this asynchronous
|
||||
task_state: Union[TaskState, List[TaskState], None] = get_task(
|
||||
task_id.hex(),
|
||||
timeout=1,
|
||||
_explain=True,
|
||||
)
|
||||
except (RayStateApiException, requests.exceptions.RequestException):
|
||||
logger.debug(f"Failed to grab task state with task_id={task_id}", exc_info=True)
|
||||
return None
|
||||
except Exception:
|
||||
logger.debug(
|
||||
f"Unexpected error when grabbing task state with task_id={task_id}",
|
||||
exc_info=True,
|
||||
)
|
||||
return None
|
||||
if isinstance(task_state, list):
|
||||
# get the latest task
|
||||
task_state = max(task_state, key=lambda ts: ts.attempt_number, default=None)
|
||||
if task_state is None:
|
||||
return None
|
||||
return TaskMetadata.from_task_state(task_state)
|
||||
@@ -0,0 +1,145 @@
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, List
|
||||
|
||||
import ray
|
||||
from ray.data._internal.execution.operators.hash_shuffle import (
|
||||
AggregatorHealthInfo,
|
||||
HashShuffleOperator,
|
||||
)
|
||||
from ray.data._internal.issue_detection.issue_detector import (
|
||||
Issue,
|
||||
IssueDetector,
|
||||
IssueType,
|
||||
)
|
||||
from ray.data._internal.util import GiB
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ray.data._internal.execution.interfaces.physical_operator import (
|
||||
PhysicalOperator,
|
||||
)
|
||||
from ray.data._internal.execution.streaming_executor import StreamingExecutor
|
||||
|
||||
|
||||
@dataclass
|
||||
class HashShuffleAggregatorIssueDetectorConfig:
|
||||
"""Configuration for HashShuffleAggregatorIssueDetector."""
|
||||
|
||||
detection_time_interval_s: float = 30.0
|
||||
min_wait_time_s: float = 300.0
|
||||
|
||||
|
||||
class HashShuffleAggregatorIssueDetector(IssueDetector):
|
||||
"""Detector for hash shuffle aggregator health issues."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
dataset_id: str,
|
||||
operators: List["PhysicalOperator"],
|
||||
config: HashShuffleAggregatorIssueDetectorConfig,
|
||||
):
|
||||
self._dataset_id = dataset_id
|
||||
self._operators = operators
|
||||
self._detector_cfg = config
|
||||
self._last_warning_times = {} # Track per-operator warning times
|
||||
|
||||
@classmethod
|
||||
def from_executor(
|
||||
cls, executor: "StreamingExecutor"
|
||||
) -> "HashShuffleAggregatorIssueDetector":
|
||||
"""Factory method to create a HashShuffleAggregatorIssueDetector from a StreamingExecutor.
|
||||
|
||||
Args:
|
||||
executor: The StreamingExecutor instance to extract dependencies from.
|
||||
|
||||
Returns:
|
||||
An instance of HashShuffleAggregatorIssueDetector.
|
||||
"""
|
||||
operators = list(executor._topology.keys()) if executor._topology else []
|
||||
ctx = executor._data_context
|
||||
return cls(
|
||||
dataset_id=executor._dataset_id,
|
||||
operators=operators,
|
||||
config=ctx.issue_detectors_config.hash_shuffle_detector_config,
|
||||
)
|
||||
|
||||
def detect(self) -> List[Issue]:
|
||||
issues = []
|
||||
current_time = time.time()
|
||||
|
||||
# Find all hash shuffle operators in the topology
|
||||
for op in self._operators:
|
||||
if not isinstance(op, HashShuffleOperator):
|
||||
continue
|
||||
|
||||
# Skip if operator doesn't have aggregator pool yet
|
||||
if op._aggregator_pool is None:
|
||||
continue
|
||||
|
||||
pool = op._aggregator_pool
|
||||
aggregator_info = pool.check_aggregator_health()
|
||||
|
||||
if aggregator_info is None:
|
||||
continue
|
||||
|
||||
# Check if we should emit a warning for this operator
|
||||
should_warn = self._should_emit_warning(
|
||||
op.id, current_time, aggregator_info
|
||||
)
|
||||
|
||||
if should_warn:
|
||||
message = self._format_health_warning(aggregator_info)
|
||||
issues.append(
|
||||
Issue(
|
||||
dataset_name=self._dataset_id,
|
||||
operator_id=op.id,
|
||||
issue_type=IssueType.HANGING,
|
||||
message=message,
|
||||
)
|
||||
)
|
||||
self._last_warning_times[op.id] = current_time
|
||||
|
||||
return issues
|
||||
|
||||
def detection_time_interval_s(self) -> float:
|
||||
return self._detector_cfg.detection_time_interval_s
|
||||
|
||||
def _should_emit_warning(
|
||||
self, op_id: str, current_time: float, info: AggregatorHealthInfo
|
||||
) -> bool:
|
||||
"""Check if we should emit a warning for this operator."""
|
||||
if not info.has_unready_aggregators:
|
||||
# Clear warning time if all aggregators are healthy
|
||||
self._last_warning_times.pop(op_id, None)
|
||||
return False
|
||||
|
||||
# Check if enough time has passed since start
|
||||
if current_time - info.started_at < self._detector_cfg.min_wait_time_s:
|
||||
return False
|
||||
|
||||
# Check if enough time has passed since last warning
|
||||
last_warning = self._last_warning_times.get(op_id)
|
||||
if last_warning is None:
|
||||
return True
|
||||
|
||||
return current_time - last_warning >= self.detection_time_interval_s()
|
||||
|
||||
def _format_health_warning(self, info: AggregatorHealthInfo) -> str:
|
||||
"""Format the health warning message."""
|
||||
available_resources = ray.available_resources()
|
||||
available_cpus = available_resources.get("CPU", 0)
|
||||
cluster_resources = ray.cluster_resources()
|
||||
total_memory = cluster_resources.get("memory", 0)
|
||||
available_memory = available_resources.get("memory", 0)
|
||||
|
||||
return (
|
||||
f"Only {info.ready_aggregators} out of {info.total_aggregators} "
|
||||
f"hash-shuffle aggregators are ready after {info.wait_time:.1f} secs. "
|
||||
f"This might indicate resource contention for cluster resources "
|
||||
f"(available CPUs: {available_cpus}, required CPUs: {info.required_resources.cpu}). "
|
||||
f"Cluster only has {available_memory / GiB:.2f} GiB available memory, required memory: {info.required_resources.memory / GiB:.2f} GiB. "
|
||||
f"{total_memory / GiB:.2f} GiB total memory. "
|
||||
f"Consider increasing cluster size or reducing the number of aggregators "
|
||||
f"via `DataContext.max_hash_shuffle_aggregators`. "
|
||||
f"Will continue checking every {self.detection_time_interval_s()}s."
|
||||
)
|
||||
@@ -0,0 +1,124 @@
|
||||
import textwrap
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Dict, List
|
||||
|
||||
from ray.data._internal.execution.operators.map_operator import (
|
||||
MapOperator,
|
||||
get_safe_default_logical_memory,
|
||||
)
|
||||
from ray.data._internal.execution.util import memory_string
|
||||
from ray.data._internal.issue_detection.issue_detector import (
|
||||
Issue,
|
||||
IssueDetector,
|
||||
IssueType,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ray.data._internal.execution.interfaces.physical_operator import (
|
||||
PhysicalOperator,
|
||||
)
|
||||
from ray.data._internal.execution.streaming_executor import StreamingExecutor
|
||||
|
||||
HIGH_MEMORY_PERIODIC_WARNING = """
|
||||
Operator '{op_name}' uses {memory_per_task} of memory per task on average, but Ray
|
||||
only requests {initial_memory_request} per task at the start of the pipeline.
|
||||
|
||||
To avoid out-of-memory errors, consider setting `memory={memory_per_task}` in the
|
||||
appropriate function or method call. (This might be unnecessary if the number of
|
||||
concurrent tasks is low.)
|
||||
|
||||
To change the frequency of this warning, set
|
||||
`DataContext.get_current().issue_detectors_config.high_memory_detector_config.detection_time_interval_s`,
|
||||
or disable the warning by setting value to -1. (current value:
|
||||
{detection_time_interval_s})
|
||||
""" # noqa: E501
|
||||
|
||||
|
||||
@dataclass
|
||||
class HighMemoryIssueDetectorConfig:
|
||||
detection_time_interval_s: float = 30
|
||||
|
||||
|
||||
class HighMemoryIssueDetector(IssueDetector):
|
||||
def __init__(
|
||||
self,
|
||||
dataset_id: str,
|
||||
operators: List["PhysicalOperator"],
|
||||
config: HighMemoryIssueDetectorConfig,
|
||||
):
|
||||
self._dataset_id = dataset_id
|
||||
self._detector_cfg = config
|
||||
self._operators = operators
|
||||
|
||||
self._initial_memory_requests: Dict[MapOperator, int] = {}
|
||||
for op in operators:
|
||||
if isinstance(op, MapOperator):
|
||||
self._initial_memory_requests[op] = (
|
||||
op._get_dynamic_ray_remote_args().get("memory") or 0
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_executor(cls, executor: "StreamingExecutor") -> "HighMemoryIssueDetector":
|
||||
"""Factory method to create a HighMemoryIssueDetector from a StreamingExecutor.
|
||||
|
||||
Args:
|
||||
executor: The StreamingExecutor instance to extract dependencies from.
|
||||
|
||||
Returns:
|
||||
An instance of HighMemoryIssueDetector.
|
||||
"""
|
||||
operators = list(executor._topology.keys()) if executor._topology else []
|
||||
ctx = executor._data_context
|
||||
return cls(
|
||||
dataset_id=executor._dataset_id,
|
||||
operators=operators,
|
||||
config=ctx.issue_detectors_config.high_memory_detector_config,
|
||||
)
|
||||
|
||||
def detect(self) -> List[Issue]:
|
||||
issues = []
|
||||
for op in self._operators:
|
||||
if not isinstance(op, MapOperator):
|
||||
continue
|
||||
|
||||
if op.metrics.average_max_uss_per_task is None:
|
||||
continue
|
||||
|
||||
remote_args = op._get_dynamic_ray_remote_args()
|
||||
safe_memory_per_task = get_safe_default_logical_memory(remote_args)
|
||||
|
||||
if (
|
||||
op.metrics.average_max_uss_per_task > self._initial_memory_requests[op]
|
||||
and op.metrics.average_max_uss_per_task >= safe_memory_per_task
|
||||
):
|
||||
message = HIGH_MEMORY_PERIODIC_WARNING.format(
|
||||
op_name=op.name,
|
||||
memory_per_task=memory_string(op.metrics.average_max_uss_per_task),
|
||||
initial_memory_request=memory_string(
|
||||
self._initial_memory_requests[op]
|
||||
),
|
||||
detection_time_interval_s=self.detection_time_interval_s(),
|
||||
)
|
||||
issues.append(
|
||||
Issue(
|
||||
dataset_name=self._dataset_id,
|
||||
operator_id=op.id,
|
||||
issue_type=IssueType.HIGH_MEMORY,
|
||||
message=_format_message(message),
|
||||
)
|
||||
)
|
||||
|
||||
return issues
|
||||
|
||||
def detection_time_interval_s(self) -> float:
|
||||
return self._detector_cfg.detection_time_interval_s
|
||||
|
||||
|
||||
def _format_message(message: str) -> str:
|
||||
# Apply some formatting to make the message look nicer when printed.
|
||||
formatted_paragraphs = []
|
||||
for paragraph in message.split("\n\n"):
|
||||
formatted_paragraph = textwrap.fill(paragraph, break_long_words=False).strip()
|
||||
formatted_paragraphs.append(formatted_paragraph)
|
||||
formatted_message = "\n\n".join(formatted_paragraphs)
|
||||
return "\n\n" + formatted_message + "\n"
|
||||
@@ -0,0 +1,44 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import TYPE_CHECKING, List
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ray.data._internal.execution.streaming_executor import StreamingExecutor
|
||||
|
||||
|
||||
class IssueType(str, Enum):
|
||||
HANGING = "hanging"
|
||||
HIGH_MEMORY = "high memory"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Issue:
|
||||
dataset_name: str
|
||||
operator_id: str
|
||||
message: str
|
||||
issue_type: IssueType
|
||||
|
||||
|
||||
class IssueDetector(ABC):
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
def from_executor(cls, executor: "StreamingExecutor") -> "IssueDetector":
|
||||
"""Factory method to create an issue detector from a StreamingExecutor.
|
||||
|
||||
Args:
|
||||
executor: The StreamingExecutor instance to extract dependencies from.
|
||||
|
||||
Returns:
|
||||
An instance of the issue detector.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def detect(self) -> List[Issue]:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def detection_time_interval_s(self) -> float:
|
||||
"""Time interval between detections, or -1 if not enabled."""
|
||||
pass
|
||||
@@ -0,0 +1,32 @@
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List, Type
|
||||
|
||||
from ray.data._internal.issue_detection.detectors import (
|
||||
HangingExecutionIssueDetectorConfig,
|
||||
HashShuffleAggregatorIssueDetector,
|
||||
HashShuffleAggregatorIssueDetectorConfig,
|
||||
HighMemoryIssueDetector,
|
||||
HighMemoryIssueDetectorConfig,
|
||||
)
|
||||
from ray.data._internal.issue_detection.issue_detector import IssueDetector
|
||||
|
||||
|
||||
@dataclass
|
||||
class IssueDetectorsConfiguration:
|
||||
hanging_detector_config: HangingExecutionIssueDetectorConfig = field(
|
||||
default_factory=HangingExecutionIssueDetectorConfig
|
||||
)
|
||||
hash_shuffle_detector_config: HashShuffleAggregatorIssueDetectorConfig = field(
|
||||
default_factory=HashShuffleAggregatorIssueDetectorConfig
|
||||
)
|
||||
high_memory_detector_config: HighMemoryIssueDetectorConfig = field(
|
||||
default_factory=HighMemoryIssueDetectorConfig
|
||||
)
|
||||
detectors: List[Type[IssueDetector]] = field(
|
||||
default_factory=lambda: [
|
||||
# TODO(Justin): Enable once it's non-blocking
|
||||
# HangingExecutionIssueDetector,
|
||||
HashShuffleAggregatorIssueDetector,
|
||||
HighMemoryIssueDetector,
|
||||
]
|
||||
)
|
||||
@@ -0,0 +1,112 @@
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from typing import TYPE_CHECKING, Dict, List, Set, Tuple
|
||||
|
||||
from ray.core.generated.export_dataset_operator_event_pb2 import (
|
||||
ExportDatasetOperatorEventData as ProtoOperatorEventData,
|
||||
)
|
||||
from ray.data._internal.issue_detection.issue_detector import (
|
||||
Issue,
|
||||
IssueDetector,
|
||||
IssueType,
|
||||
)
|
||||
from ray.data._internal.operator_event_exporter import (
|
||||
OperatorEvent,
|
||||
format_export_issue_event_name,
|
||||
get_operator_event_exporter,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ray.data._internal.execution.interfaces.physical_operator import (
|
||||
PhysicalOperator,
|
||||
)
|
||||
from ray.data._internal.execution.streaming_executor import StreamingExecutor
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class IssueDetectorManager:
|
||||
def __init__(self, executor: "StreamingExecutor"):
|
||||
ctx = executor._data_context
|
||||
self._issue_detectors: List[IssueDetector] = [
|
||||
cls.from_executor(executor) for cls in ctx.issue_detectors_config.detectors
|
||||
]
|
||||
self._last_detection_times: Dict[IssueDetector, float] = {
|
||||
detector: time.perf_counter() for detector in self._issue_detectors
|
||||
}
|
||||
self.executor = executor
|
||||
self._operator_event_exporter = get_operator_event_exporter()
|
||||
# Set of detected (issue_type, operator) pairs for usage collection.
|
||||
self._detected_issues: Set[Tuple[IssueType, "PhysicalOperator"]] = set()
|
||||
# We protect the above set with a lock to avoid race conditions between the executor thread, that invokes the detectors (adding to the set of detected issues), and the
|
||||
# consumer thread that checks the set of detected issues on shutdown (in the usage callback).
|
||||
self._detected_issues_lock = threading.Lock()
|
||||
|
||||
def invoke_detectors(self) -> None:
|
||||
curr_time = time.perf_counter()
|
||||
issues = []
|
||||
for detector in self._issue_detectors:
|
||||
if detector.detection_time_interval_s() == -1:
|
||||
continue
|
||||
|
||||
if (
|
||||
curr_time - self._last_detection_times[detector]
|
||||
> detector.detection_time_interval_s()
|
||||
):
|
||||
issues.extend(detector.detect())
|
||||
|
||||
self._last_detection_times[detector] = time.perf_counter()
|
||||
|
||||
self._report_issues(issues)
|
||||
|
||||
def _report_issues(self, issues: List[Issue]) -> None:
|
||||
operators: Dict[str, "PhysicalOperator"] = {}
|
||||
op_to_id: Dict["PhysicalOperator", str] = {}
|
||||
for i, operator in enumerate(self.executor._topology.keys()):
|
||||
operators[operator.id] = operator
|
||||
op_to_id[operator] = self.executor._get_operator_id(operator, i)
|
||||
# Reset issue detector metrics for each operator so that previous issues
|
||||
# don't affect the current ones.
|
||||
operator.metrics._issue_detector_hanging = 0
|
||||
operator.metrics._issue_detector_high_memory = 0
|
||||
|
||||
for issue in issues:
|
||||
logger.warning(issue.message)
|
||||
operator = operators.get(issue.operator_id)
|
||||
if not operator:
|
||||
continue
|
||||
|
||||
with self._detected_issues_lock:
|
||||
self._detected_issues.add((issue.issue_type, operator))
|
||||
|
||||
issue_event_type = format_export_issue_event_name(issue.issue_type)
|
||||
if (
|
||||
self._operator_event_exporter is not None
|
||||
and issue_event_type
|
||||
in ProtoOperatorEventData.DatasetOperatorEventType.keys()
|
||||
):
|
||||
event_time = time.time()
|
||||
operator_event = OperatorEvent(
|
||||
dataset_id=issue.dataset_name,
|
||||
operator_id=op_to_id[operator],
|
||||
operator_name=operator.name,
|
||||
event_time=event_time,
|
||||
event_type=issue_event_type,
|
||||
message=issue.message,
|
||||
)
|
||||
self._operator_event_exporter.export_operator_event(operator_event)
|
||||
|
||||
if issue.issue_type == IssueType.HANGING:
|
||||
operator.metrics._issue_detector_hanging += 1
|
||||
if issue.issue_type == IssueType.HIGH_MEMORY:
|
||||
operator.metrics._issue_detector_high_memory += 1
|
||||
if len(issues) > 0:
|
||||
logger.warning(
|
||||
f"Found {len(issues)} issues. To disable issue detection, run DataContext.get_current().issue_detectors_config.detectors = []."
|
||||
)
|
||||
|
||||
def get_detected_issues(self) -> Set[Tuple[IssueType, "PhysicalOperator"]]:
|
||||
"""Return a copy of the detected (issue_type, operator) pairs."""
|
||||
with self._detected_issues_lock:
|
||||
return set(self._detected_issues)
|
||||
Reference in New Issue
Block a user