chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from ray.data._internal.usage.collector import (
|
||||
EnvInfo,
|
||||
Issue,
|
||||
LogicalOp,
|
||||
OpConfig,
|
||||
PipelinePerf,
|
||||
PlanNode,
|
||||
UsageInfo,
|
||||
WorkloadInfo,
|
||||
record_usage_info,
|
||||
)
|
||||
from ray.data._internal.usage.execution_callback import UsageCallback
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ray.data._internal.logical.interfaces.logical_plan import LogicalPlan
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def create_usage_callback(logical_plan: "LogicalPlan") -> UsageCallback:
|
||||
"""Create the usage callback for an execution.
|
||||
|
||||
Factory method to return a ``UsageCallback`` object.
|
||||
"""
|
||||
return UsageCallback(logical_plan)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"EnvInfo",
|
||||
"Issue",
|
||||
"LogicalOp",
|
||||
"OpConfig",
|
||||
"PipelinePerf",
|
||||
"PlanNode",
|
||||
"UsageCallback",
|
||||
"UsageInfo",
|
||||
"WorkloadInfo",
|
||||
"create_usage_callback",
|
||||
"record_usage_info",
|
||||
]
|
||||
@@ -0,0 +1,425 @@
|
||||
"""Ray Data usage-stats collector.
|
||||
|
||||
Accumulates per-execution usage data (environment, workload description,
|
||||
performance) and flushes it to GCS via ``record_extra_usage_tag``.
|
||||
|
||||
The usage payload for each execution is assembled by :class:`UsageCallback`
|
||||
this module owns the process-global buffer of recent executions and the builder functions
|
||||
collecting usage data.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import importlib.metadata
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
from collections import OrderedDict
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from functools import cache
|
||||
from typing import TYPE_CHECKING, Callable, Dict, List, Optional, Tuple
|
||||
|
||||
import ray
|
||||
from ray._common.usage.usage_lib import (
|
||||
TagKey,
|
||||
record_extra_usage_tag,
|
||||
usage_stats_enabled,
|
||||
)
|
||||
from ray._private.internal_api import get_memory_info_reply, get_state_from_address
|
||||
from ray._private.worker import global_worker
|
||||
from ray.core.generated.gcs_pb2 import GcsNodeInfo
|
||||
from ray.data._internal.logical.interfaces import LogicalOperator
|
||||
from ray.data._internal.logical.operators import MapBatches
|
||||
from ray.data._internal.usage.util import anonymize_op_name
|
||||
from ray.data.block import VALID_BATCH_FORMATS, _apply_batch_format
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ray.data._internal.execution.interfaces.physical_operator import (
|
||||
PhysicalOperator,
|
||||
)
|
||||
from ray.data._internal.issue_detection.issue_detector import IssueType
|
||||
from ray.data._internal.logical.interfaces.logical_plan import LogicalPlan
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Bounded timeout for the GCS get_all_node_info query used to count dead nodes.
|
||||
_NODE_INFO_RPC_TIMEOUT_S = 5.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OpConfig:
|
||||
"""Configuration for an operator"""
|
||||
|
||||
batch_format: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LogicalOp:
|
||||
"""An operator in the plan"""
|
||||
|
||||
usage_id: str
|
||||
name: str
|
||||
config: Optional[OpConfig] = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PlanNode:
|
||||
"""A node in the anonymized plan tree (one logical operator)."""
|
||||
|
||||
usage_id: str
|
||||
op: str
|
||||
inputs: List["PlanNode"] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WorkloadInfo:
|
||||
"""The anonymized plan tree, a human-readable rendering of it, and the
|
||||
per-op flat list (with config)."""
|
||||
|
||||
plan: PlanNode
|
||||
plan_str: str
|
||||
ops: List[LogicalOp]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EnvInfo:
|
||||
pyarrow: Optional[str]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PipelinePerf:
|
||||
bytes_spilled: Optional[int]
|
||||
oom_kills: Optional[int] = None
|
||||
unexpected_worker_kills: Optional[int] = None
|
||||
node_deaths: Optional[int] = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Issue:
|
||||
"""An issue detected during execution, tied to an anonymized operator."""
|
||||
|
||||
issue_type: str
|
||||
operator: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class UsageInfo:
|
||||
"""Per-execution usage payload: the entry buffered and flushed to GCS."""
|
||||
|
||||
id: str
|
||||
started_at: float
|
||||
env: EnvInfo
|
||||
workload: WorkloadInfo
|
||||
performance: Optional[PipelinePerf] = None
|
||||
detected_issues: List[Issue] = field(default_factory=list)
|
||||
|
||||
|
||||
# A callable that records config information for a logical operator.
|
||||
OpConfigFn = Callable[[LogicalOperator], Optional[OpConfig]]
|
||||
|
||||
# A callable that returns process-wide environment info. Overridable so
|
||||
# subclasses can collect richer env details.
|
||||
EnvFn = Callable[[], EnvInfo]
|
||||
|
||||
# A callable that returns the anonymized name for a logical operator.
|
||||
# Allows subclasses to add custom anonymization logic.
|
||||
OpNameFn = Callable[[LogicalOperator], str]
|
||||
|
||||
# A callable that samples a cluster metric (spilled bytes, dead node
|
||||
# count, ...)
|
||||
MetricReader = Callable[[], Optional[int]]
|
||||
|
||||
|
||||
# Bounded buffer of recent executions. OrderedDict so eviction picks the
|
||||
# oldest-inserted entry
|
||||
_MAX_EXECUTIONS_TO_TRACK = 100
|
||||
|
||||
# Module state. Mutations are serialized through ``_lock``.
|
||||
_executions: "OrderedDict[str, UsageInfo]" = OrderedDict()
|
||||
_lock = threading.Lock()
|
||||
|
||||
|
||||
def usage_collection_disabled() -> bool:
|
||||
"""True when the user has opted out of usage stats (via
|
||||
``RAY_USAGE_STATS_ENABLED=0``, ``ray disable-usage-stats``, or
|
||||
``~/.ray/config.json``) or when ``RAY_DATA_USAGE_DISABLED=1`` is set.
|
||||
"""
|
||||
return not usage_stats_enabled() or os.environ.get("RAY_DATA_USAGE_DISABLED") == "1"
|
||||
|
||||
|
||||
def cluster_spilled_bytes() -> Optional[int]:
|
||||
"""Cluster-wide cumulative spilled bytes from Ray core's store_stats.
|
||||
|
||||
Returns None on any failure — usage collection must never break execution.
|
||||
"""
|
||||
if not ray.is_initialized():
|
||||
return None
|
||||
try:
|
||||
reply = get_memory_info_reply(
|
||||
get_state_from_address(ray.get_runtime_context().gcs_address),
|
||||
timeout_seconds=10.0,
|
||||
)
|
||||
return int(reply.store_stats.spilled_bytes_total)
|
||||
except Exception:
|
||||
logger.debug("Failed to read cluster spilled bytes", exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
def cluster_dead_node_count() -> Optional[int]:
|
||||
"""Number of dead nodes in the GCS node table.
|
||||
|
||||
Queries GCS with a bounded timeout and a server-side DEAD state filter.
|
||||
Returns None on any failure.
|
||||
"""
|
||||
if not ray.is_initialized():
|
||||
return None
|
||||
try:
|
||||
gcs_client = global_worker.gcs_client # pyrefly: ignore[missing-attribute]
|
||||
dead_nodes = gcs_client.get_all_node_info(
|
||||
timeout=_NODE_INFO_RPC_TIMEOUT_S,
|
||||
state_filter=GcsNodeInfo.GcsNodeState.DEAD,
|
||||
)
|
||||
return len(dead_nodes)
|
||||
except Exception:
|
||||
logger.debug("Failed to read cluster dead node count", exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
def compute_delta(start: Optional[int], end: Optional[int]) -> Optional[int]:
|
||||
"""Non-negative delta between two cumulative samples. Returns None if
|
||||
either sample is missing"""
|
||||
if start is None or end is None:
|
||||
return None
|
||||
return max(0, end - start)
|
||||
|
||||
|
||||
def record_usage_info(info: UsageInfo) -> None:
|
||||
"""Buffer ``info`` (evicting the oldest entry when full) and flush the whole
|
||||
buffer to GCS via ``record_extra_usage_tag``.
|
||||
|
||||
The callback calls this both before execution starts (so attempted
|
||||
executions are captured even if the execution fails) and after it finishes
|
||||
(to overwrite the same entry with performance and issue data).
|
||||
|
||||
Short-circuits when the user has opted out of Ray usage stats (via
|
||||
``RAY_USAGE_STATS_ENABLED=0``, ``ray disable-usage-stats``, or
|
||||
``~/.ray/config.json``) or when ``RAY_DATA_USAGE_DISABLED=1`` is set.
|
||||
"""
|
||||
if usage_collection_disabled():
|
||||
return
|
||||
try:
|
||||
with _lock:
|
||||
if (
|
||||
info.id not in _executions
|
||||
and len(_executions) >= _MAX_EXECUTIONS_TO_TRACK
|
||||
):
|
||||
_executions.popitem(last=False)
|
||||
_executions[info.id] = info
|
||||
payload = _serialize_locked()
|
||||
record_extra_usage_tag(TagKey.DATA_USAGE, payload)
|
||||
except Exception:
|
||||
logger.debug("Failed to record usage info", exc_info=True)
|
||||
|
||||
|
||||
def build_usage_id_map(
|
||||
logical_plan: "LogicalPlan",
|
||||
op_name_fn: Optional[OpNameFn] = None,
|
||||
) -> Dict[int, str]:
|
||||
"""Build the ``id(logical_op) -> usage_id`` map for a plan.
|
||||
|
||||
The IDs are computed based on the hash of the (post-order index, anonymized name) tuple. These are
|
||||
used to identify logical ops after anonymization (i.e. MapBatches-<id1>, MapBatches-<id2>, etc.).
|
||||
|
||||
Short-circuits to an empty map when the user has opted out of usage stats:
|
||||
without a recorded payload there is nothing for the IDs to reference.
|
||||
"""
|
||||
if usage_collection_disabled():
|
||||
return {}
|
||||
if op_name_fn is None:
|
||||
op_name_fn = anonymize_op_name
|
||||
try:
|
||||
ordered_logical_ops: List[Tuple[LogicalOperator, str]] = []
|
||||
_build_plan(logical_plan.dag, ordered_logical_ops, op_name_fn)
|
||||
return {id(op): usage_id for op, usage_id in ordered_logical_ops}
|
||||
except Exception:
|
||||
logger.debug("Failed to build usage id map", exc_info=True)
|
||||
return {}
|
||||
|
||||
|
||||
def physical_op_name_with_id(
|
||||
operator: "PhysicalOperator",
|
||||
usage_id_map: Optional[Dict[int, str]] = None,
|
||||
op_name_fn: Optional[OpNameFn] = None,
|
||||
) -> str:
|
||||
"""Anonymized name for a physical op. Fused ops join their constituent logical
|
||||
ops with '->' to signal operator fusion. We need physical op name as
|
||||
issues from the issue detector reference physical ops."""
|
||||
if op_name_fn is None:
|
||||
op_name_fn = anonymize_op_name
|
||||
logical_ops = operator._logical_operators
|
||||
if not logical_ops:
|
||||
return "Unknown"
|
||||
return "->".join(
|
||||
_logical_op_name_with_id(op, usage_id_map, op_name_fn) for op in logical_ops
|
||||
)
|
||||
|
||||
|
||||
def _logical_op_name_with_id(
|
||||
logical_op: LogicalOperator,
|
||||
usage_id_map: Optional[Dict[int, str]] = None,
|
||||
op_name_fn: OpNameFn = anonymize_op_name,
|
||||
) -> str:
|
||||
"""Logical op is formatted as ``<anonymized_name>-<usage_id>``. The usage ID map is populated before execution starts in the usage callback."""
|
||||
name = op_name_fn(logical_op)
|
||||
if usage_id_map:
|
||||
# Correlate with the IDs assigned to the logical ops in the workload plan.
|
||||
usage_id = usage_id_map.get(id(logical_op))
|
||||
if usage_id is not None:
|
||||
return f"{name}-{usage_id}"
|
||||
return name
|
||||
|
||||
|
||||
def collect_issues(
|
||||
detected_issues: Optional[List[Tuple["IssueType", str]]],
|
||||
) -> List[Issue]:
|
||||
"""Convert (issue_type, operator) pairs into ``Issue`` records, mapping
|
||||
each ``IssueType`` enum to its string value."""
|
||||
if not detected_issues:
|
||||
return []
|
||||
return [
|
||||
Issue(issue_type=issue_type.value, operator=operator)
|
||||
for issue_type, operator in detected_issues
|
||||
]
|
||||
|
||||
|
||||
def _serialize_locked() -> str:
|
||||
"""Serialize current state to JSON. Caller must hold ``_lock``."""
|
||||
return json.dumps({"executions": [asdict(e) for e in _executions.values()]})
|
||||
|
||||
|
||||
def collect_env() -> EnvInfo:
|
||||
"""Process-wide environment info."""
|
||||
return EnvInfo(pyarrow=_safe_version("pyarrow"))
|
||||
|
||||
|
||||
def _safe_version(pkg: str) -> Optional[str]:
|
||||
try:
|
||||
return importlib.metadata.version(pkg)
|
||||
except importlib.metadata.PackageNotFoundError:
|
||||
return None
|
||||
|
||||
|
||||
def collect_workload(
|
||||
logical_plan: "LogicalPlan",
|
||||
op_config_fn: Optional[OpConfigFn] = None,
|
||||
op_name_fn: Optional[OpNameFn] = None,
|
||||
) -> WorkloadInfo:
|
||||
"""Collect the anonymized plan tree, indented text rendering, and per-op
|
||||
config list in a single DAG walk.
|
||||
|
||||
``op_config_fn`` builds the per-op config; it defaults to ``collect_op_config``
|
||||
and is overridable if subclasses need to extract custom config info.
|
||||
``op_name_fn`` anonymizes each operator's name; it defaults to
|
||||
``anonymize_op_name`` and is overridable the same way.
|
||||
"""
|
||||
if op_config_fn is None:
|
||||
op_config_fn = collect_op_config
|
||||
if op_name_fn is None:
|
||||
op_name_fn = anonymize_op_name
|
||||
dag = logical_plan.dag
|
||||
ordered_logical_ops: List[Tuple[LogicalOperator, str]] = []
|
||||
plan = _build_plan(dag, ordered_logical_ops, op_name_fn)
|
||||
return WorkloadInfo(
|
||||
plan=plan,
|
||||
plan_str=_format_plan_str(dag, op_name_fn),
|
||||
ops=_build_ops(ordered_logical_ops, op_config_fn, op_name_fn),
|
||||
)
|
||||
|
||||
|
||||
def _build_plan(
|
||||
op: LogicalOperator,
|
||||
ordered_logical_ops: List[Tuple[LogicalOperator, str]],
|
||||
op_name_fn: OpNameFn,
|
||||
) -> PlanNode:
|
||||
"""Build the plan tree and record logical ops in post-order.
|
||||
|
||||
Deduplicates shared operator instances (e.g. ``ds.zip(ds)``), so each
|
||||
operator is assigned a single usage_id even when reachable via multiple
|
||||
plan branches.
|
||||
"""
|
||||
|
||||
@cache
|
||||
def _build_cached(op: LogicalOperator) -> PlanNode:
|
||||
child_plans = [_build_cached(child) for child in op.input_dependencies]
|
||||
name = op_name_fn(op)
|
||||
usage_id = make_usage_op_id(len(ordered_logical_ops), name)
|
||||
ordered_logical_ops.append((op, usage_id))
|
||||
return PlanNode(usage_id=usage_id, op=name, inputs=child_plans)
|
||||
|
||||
return _build_cached(op)
|
||||
|
||||
|
||||
def _build_ops(
|
||||
ordered_logical_ops: List[Tuple[LogicalOperator, str]],
|
||||
op_config_fn: OpConfigFn,
|
||||
op_name_fn: OpNameFn,
|
||||
) -> List[LogicalOp]:
|
||||
"""Build the flat logical-op list from the canonical post-order traversal."""
|
||||
ops: List[LogicalOp] = []
|
||||
for op, usage_id in ordered_logical_ops:
|
||||
name = op_name_fn(op)
|
||||
ops.append(
|
||||
LogicalOp(
|
||||
usage_id=usage_id,
|
||||
name=name,
|
||||
config=op_config_fn(op),
|
||||
)
|
||||
)
|
||||
return ops
|
||||
|
||||
|
||||
def collect_op_config(op: LogicalOperator) -> Optional[OpConfig]:
|
||||
# MapBatches is the only operator with a user-facing batch_format.
|
||||
if not isinstance(op, MapBatches):
|
||||
return None
|
||||
batch_format = op.batch_format
|
||||
if batch_format == "default":
|
||||
batch_format = _apply_batch_format(batch_format)
|
||||
if batch_format in VALID_BATCH_FORMATS:
|
||||
return OpConfig(batch_format=batch_format)
|
||||
logger.debug(f"Unexpected batch format: {batch_format!r}")
|
||||
return OpConfig(batch_format="unknown")
|
||||
|
||||
|
||||
def make_usage_op_id(index: int, name: str) -> str:
|
||||
return hashlib.sha256(f"{index}:{name}".encode()).hexdigest()[:4]
|
||||
|
||||
|
||||
def _format_plan_str(
|
||||
op: LogicalOperator,
|
||||
op_name_fn: OpNameFn = anonymize_op_name,
|
||||
depth: int = 0,
|
||||
) -> str:
|
||||
"""Render the anonymized DAG as an indented tree, using ``op_name_fn`` to
|
||||
avoid leaking UDF / datasource details.
|
||||
"""
|
||||
name = op_name_fn(op)
|
||||
if depth == 0:
|
||||
line = f"{name}\n"
|
||||
else:
|
||||
line = f"{' ' * ((depth - 1) * 3)}+- {name}\n"
|
||||
for child in op.input_dependencies:
|
||||
line += _format_plan_str(child, op_name_fn, depth + 1)
|
||||
return line
|
||||
|
||||
|
||||
def reset_for_testing() -> None:
|
||||
"""Reset module state. Tests only."""
|
||||
with _lock:
|
||||
_executions.clear()
|
||||
|
||||
|
||||
def get_executions() -> "OrderedDict[str, UsageInfo]":
|
||||
"""Get the current executions. Tests only."""
|
||||
with _lock:
|
||||
return _executions.copy()
|
||||
@@ -0,0 +1,169 @@
|
||||
"""Execution-side usage-stats hook.
|
||||
|
||||
The callback is injected with the logical plan during planning. It
|
||||
records the workload entry (DAG, env, configs) before execution starts, then
|
||||
adds performance data and issues detected after execution
|
||||
finishes, flushing the payload to GCS at execution start and end so attempted executions
|
||||
are captured even if they fail.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from typing import TYPE_CHECKING, Dict, List, Optional, Tuple
|
||||
|
||||
from ray.data._internal.execution.execution_callback import ExecutionCallback
|
||||
from ray.data._internal.usage import collector, util
|
||||
from ray.data._internal.usage.collector import (
|
||||
OpConfig,
|
||||
PipelinePerf,
|
||||
UsageInfo,
|
||||
WorkloadInfo,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ray.data._internal.execution.streaming_executor import StreamingExecutor
|
||||
from ray.data._internal.issue_detection.issue_detector import IssueType
|
||||
from ray.data._internal.logical.interfaces.logical_operator import LogicalOperator
|
||||
from ray.data._internal.logical.interfaces.logical_plan import LogicalPlan
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class UsageCallback(ExecutionCallback):
|
||||
"""Records per-execution usage data."""
|
||||
|
||||
def __init__(self, logical_plan: "LogicalPlan"):
|
||||
self._logical_plan = logical_plan
|
||||
# Globally unique per-execution id, used for deduplicating executions for usage collection
|
||||
self._execution_id = uuid.uuid4().hex
|
||||
# id(logical_op) -> usage_id, built while assembling the payload and used
|
||||
# to label operators so they reference the workload payload.
|
||||
self._usage_id_map: Dict[int, str] = {}
|
||||
# The workload tree and usage-id map derive only from the (immutable)
|
||||
# logical plan, so they're computed once in the start, cached for the execution end
|
||||
self._workload: Optional[WorkloadInfo] = None
|
||||
self._started_at: Optional[float] = None
|
||||
self._spilled_at_start: Optional[int] = None
|
||||
self._spilled_at_end: Optional[int] = None
|
||||
self._dead_nodes_at_start: Optional[int] = None
|
||||
self._dead_nodes_at_end: Optional[int] = None
|
||||
self._executor: Optional["StreamingExecutor"] = None
|
||||
self._finished = False
|
||||
|
||||
# --- ExecutionCallback interface ---
|
||||
|
||||
def before_execution_starts(self, executor: "StreamingExecutor") -> None:
|
||||
if collector.usage_collection_disabled():
|
||||
return
|
||||
try:
|
||||
self._executor = executor
|
||||
self.on_collection_start(executor)
|
||||
collector.record_usage_info(self.build_usage_info())
|
||||
except Exception:
|
||||
logger.debug("Usage collection failed at start", exc_info=True)
|
||||
|
||||
def after_execution_succeeds(self, executor: "StreamingExecutor") -> None:
|
||||
self._finish(executor, None)
|
||||
|
||||
def after_execution_fails(
|
||||
self, executor: "StreamingExecutor", error: Exception
|
||||
) -> None:
|
||||
self._finish(executor, error)
|
||||
|
||||
def _finish(
|
||||
self, executor: "StreamingExecutor", error: Optional[Exception]
|
||||
) -> None:
|
||||
if collector.usage_collection_disabled():
|
||||
return
|
||||
try:
|
||||
self._executor = executor
|
||||
self._finished = True
|
||||
self.on_collection_end(executor, error)
|
||||
collector.record_usage_info(self.build_usage_info())
|
||||
except Exception:
|
||||
logger.debug("Usage collection failed at finish", exc_info=True)
|
||||
|
||||
# --- extension surface ---
|
||||
|
||||
def collect_op_config(self, op: "LogicalOperator") -> Optional[OpConfig]:
|
||||
"""Build the config entry for one operator in the workload payload."""
|
||||
return collector.collect_op_config(op)
|
||||
|
||||
def anonymize_op_name(self, op: "LogicalOperator") -> str:
|
||||
"""Anonymized name for one operator in the workload payload.
|
||||
|
||||
The default policy lives in
|
||||
``ray.data._internal.usage.util.anonymize_op_name`` because it's a
|
||||
utility shared with the legacy ``record_operators_usage`` path.
|
||||
"""
|
||||
return util.anonymize_op_name(op)
|
||||
|
||||
def on_collection_start(self, executor: "StreamingExecutor") -> None:
|
||||
"""Called once before execution starts. Records start timing and the
|
||||
cluster metric baselines used to compute per-execution deltas."""
|
||||
self._started_at = time.time()
|
||||
self._spilled_at_start = collector.cluster_spilled_bytes()
|
||||
self._dead_nodes_at_start = collector.cluster_dead_node_count()
|
||||
|
||||
def on_collection_end(
|
||||
self, executor: "StreamingExecutor", error: Optional[Exception]
|
||||
) -> None:
|
||||
"""Called once after execution succeeds or fails. Records the ending
|
||||
cluster metric samples. ``error`` is the failure (or ``None`` on
|
||||
success); subclasses may override to capture it."""
|
||||
self._spilled_at_end = collector.cluster_spilled_bytes()
|
||||
self._dead_nodes_at_end = collector.cluster_dead_node_count()
|
||||
|
||||
def build_usage_info(self) -> UsageInfo:
|
||||
"""Assemble the usage collection payload for this execution."""
|
||||
if self._workload is None:
|
||||
self._usage_id_map = collector.build_usage_id_map(
|
||||
self._logical_plan, self.anonymize_op_name
|
||||
)
|
||||
self._workload = collector.collect_workload(
|
||||
self._logical_plan, self.collect_op_config, self.anonymize_op_name
|
||||
)
|
||||
performance = None
|
||||
if self._finished:
|
||||
performance = PipelinePerf(
|
||||
bytes_spilled=collector.compute_delta(
|
||||
self._spilled_at_start, self._spilled_at_end
|
||||
),
|
||||
node_deaths=collector.compute_delta(
|
||||
self._dead_nodes_at_start, self._dead_nodes_at_end
|
||||
),
|
||||
)
|
||||
# Both are populated before this runs: on_collection_start sets
|
||||
# _started_at, and before_execution_starts/_finish set _executor.
|
||||
assert self._started_at is not None
|
||||
assert self._executor is not None
|
||||
return UsageInfo(
|
||||
id=self._execution_id,
|
||||
started_at=self._started_at,
|
||||
env=collector.collect_env(),
|
||||
workload=self._workload,
|
||||
performance=performance,
|
||||
detected_issues=collector.collect_issues(
|
||||
self._collect_detected_issues(self._executor)
|
||||
),
|
||||
)
|
||||
|
||||
def _collect_detected_issues(
|
||||
self, executor: "StreamingExecutor"
|
||||
) -> List[Tuple["IssueType", str]]:
|
||||
# The manager is None when issue detection isn't registered.
|
||||
manager = executor.issue_detector_manager
|
||||
if manager is None:
|
||||
return []
|
||||
issues = (
|
||||
(
|
||||
issue_type,
|
||||
collector.physical_op_name_with_id(
|
||||
operator, self._usage_id_map, self.anonymize_op_name
|
||||
),
|
||||
)
|
||||
for issue_type, operator in manager.get_detected_issues()
|
||||
)
|
||||
# Sort by the issue type's string value, then by the operator name.
|
||||
return sorted(issues, key=lambda issue: (issue[0].value, issue[1]))
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Utility functions for operator naming and logical-op usage recording for Ray Data telemetry.
|
||||
"""
|
||||
|
||||
import json
|
||||
import threading
|
||||
from typing import Dict
|
||||
|
||||
from ray._common.usage.usage_lib import TagKey, record_extra_usage_tag
|
||||
from ray.data._internal.logical.interfaces import LogicalOperator
|
||||
from ray.data._internal.logical.operators import Read, ReadFiles, Write
|
||||
|
||||
# The dictionary for the operator name and count.
|
||||
_recorded_operators = dict()
|
||||
_recorded_operators_lock = threading.Lock()
|
||||
|
||||
|
||||
def _is_builtin_cls(cls: type) -> bool:
|
||||
"""Return True if ``cls`` is defined under the ``ray`` package.
|
||||
|
||||
Used to gate which operator / datasource / datasink class names are safe
|
||||
to surface in telemetry. Anything outside ``ray.*`` is treated as
|
||||
user-defined and anonymized.
|
||||
"""
|
||||
return (cls.__module__ or "").startswith("ray.")
|
||||
|
||||
|
||||
def record_operators_usage(op: LogicalOperator):
|
||||
"""Record logical operator usage with Ray telemetry."""
|
||||
ops_dict = dict()
|
||||
_collect_operators_to_dict(op, ops_dict)
|
||||
ops_json_str = ""
|
||||
with _recorded_operators_lock:
|
||||
for op_name, count in ops_dict.items():
|
||||
_recorded_operators.setdefault(op_name, 0)
|
||||
_recorded_operators[op_name] += count
|
||||
ops_json_str = json.dumps(_recorded_operators)
|
||||
|
||||
record_extra_usage_tag(TagKey.DATA_LOGICAL_OPS, ops_json_str)
|
||||
|
||||
|
||||
def anonymize_op_name(op: LogicalOperator) -> str:
|
||||
"""Return an op name suitable for usage collection.
|
||||
|
||||
Read/Write surface their datasource/datasink suffix (``ReadParquet``,
|
||||
``WriteIceberg``) when the underlying class ships under ``ray.data.*``;
|
||||
user-defined datasources/datasinks collapse to ``ReadCustom`` /
|
||||
``WriteCustom``. ``ReadFiles`` (the V2 file-read op) surfaces its
|
||||
format via ``datasource_name`` (e.g. ``ReadFilesParquetV2``) when the
|
||||
scanner class is built-in; user-defined scanners collapse to
|
||||
``ReadFilesCustom``. All other built-in operators emit their class
|
||||
name (``Sort``, ``MapBatches``, ``Limit``, …); user-defined
|
||||
``LogicalOperator`` subclasses collapse to ``Unknown``.
|
||||
"""
|
||||
if isinstance(op, Read):
|
||||
if _is_builtin_cls(type(op.datasource)):
|
||||
return f"Read{op.datasource.get_name()}"
|
||||
return "ReadCustom"
|
||||
if isinstance(op, Write):
|
||||
sink = op.datasink_or_legacy_datasource
|
||||
if _is_builtin_cls(type(sink)):
|
||||
return f"Write{sink.get_name()}"
|
||||
return "WriteCustom"
|
||||
if isinstance(op, ReadFiles):
|
||||
# Gate on the scanner class — the string ``datasource_name`` field
|
||||
# could be set to anything by a user-defined V2 datasource, so it's
|
||||
# not safe to surface on its own.
|
||||
if _is_builtin_cls(type(op.scanner)):
|
||||
return f"ReadFiles{op.datasource_name}"
|
||||
return "ReadFilesCustom"
|
||||
cls = type(op)
|
||||
return cls.__name__ if _is_builtin_cls(cls) else "Unknown"
|
||||
|
||||
|
||||
def _collect_operators_to_dict(op: LogicalOperator, ops_dict: Dict[str, int]):
|
||||
"""Collect the logical operator name and count into `ops_dict`."""
|
||||
for child in op.input_dependencies:
|
||||
_collect_operators_to_dict(child, ops_dict)
|
||||
|
||||
op_name = anonymize_op_name(op)
|
||||
ops_dict.setdefault(op_name, 0)
|
||||
ops_dict[op_name] += 1
|
||||
Reference in New Issue
Block a user