blob: 98e9c91b6830c09c8ebabebf5fb9d8f29b577dbb [file]
#
# 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.
#
"""Base classes and registry for the eval type handlers.
This leaf module imports only ``_typing``, the serializers and ``pyspark.util``, so the
package ``__init__`` and the concrete-handler submodules can both import it.
"""
import functools
from abc import ABCMeta, abstractmethod
from collections.abc import Iterator
from typing import TYPE_CHECKING, Any, Callable, ClassVar, Generic, Optional
from pyspark.errors import PySparkRuntimeError
from pyspark.eval_handlers._typing import (
UDF,
UDTF,
CoGroupedBatch,
GroupedBatch,
InputBatch,
OutputBatch,
)
from pyspark.serializers import Serializer
from pyspark.sql.pandas.serializers import (
ArrowStreamCoGroupSerializer,
ArrowStreamGroupSerializer,
ArrowStreamSerializer,
)
from pyspark.util import PythonEvalType, fail_on_stopiteration
if TYPE_CHECKING:
import pyarrow as pa # noqa: F401 # only in the batch category's forward-ref subscript
from pyspark.worker_util import EvalConf, RunnerConf
# eval type -> (variant_conf or None, {variant_value: handler class}), populated by
# _EvalTypeHandlerMeta at class definition. Handlers that declare no variant_conf register
# under the conf name None and the value None.
_eval_type_handlers: "dict[int, tuple[Optional[str], dict[Any, type[EvalTypeHandler]]]]" = {}
def get_eval_type_handler(
eval_type: int, runner_conf: "RunnerConf"
) -> "Optional[type[EvalTypeHandler]]":
"""Return the handler class registered for ``eval_type``, or ``None``.
When the eval type's handlers declare a ``variant_conf``, the variant is selected by the
value of that ``RunnerConf`` property. This happens per lookup rather than at import, as
the conf is per task and Python workers are reused across tasks.
"""
entry = _eval_type_handlers.get(eval_type)
if entry is None:
return None
conf_name, variants = entry
key = getattr(runner_conf, conf_name) if conf_name is not None else None
handler_cls = variants.get(key)
if handler_cls is None:
raise RuntimeError(
"No eval type handler for {} with {}={!r}; registered values: {}".format(
eval_type, conf_name, key, sorted(map(repr, variants))
)
)
return handler_cls
class _EvalTypeHandlerMeta(ABCMeta):
"""Registers a concrete handler under its ``eval_type`` (and ``variant_value``, if it
declares a ``variant_conf``) at class definition.
Runs after ``ABCMeta`` sets ``__abstractmethods__``, so a class that declares
an ``eval_type`` while leaving ``run``/``serializer`` abstract is rejected at
definition.
"""
def __new__(mcs, name: str, bases: tuple, namespace: dict, **kwargs: Any) -> type:
cls = super().__new__(mcs, name, bases, namespace, **kwargs)
eval_type = namespace.get("eval_type")
if eval_type is not None:
assert issubclass(cls, EvalTypeHandler)
assert not cls.__abstractmethods__, (
"Handler {} declares eval_type {} but is abstract: {}".format(
name, eval_type, sorted(cls.__abstractmethods__)
)
)
conf_name = namespace.get("variant_conf")
assert conf_name is not None or "variant_value" not in namespace, (
"Handler {} declares variant_value without variant_conf".format(name)
)
assert conf_name is None or "variant_value" in namespace, (
"Handler {} declares variant_conf {} without variant_value".format(name, conf_name)
)
value = namespace.get("variant_value")
registered_conf_name, variants = _eval_type_handlers.get(eval_type, (conf_name, {}))
assert registered_conf_name == conf_name, (
"Handlers for eval type {} must share one variant_conf: {} keys on {}, "
"but {} keys on {}".format(
eval_type,
next(iter(variants.values())).__name__,
registered_conf_name,
name,
conf_name,
)
)
assert value not in variants, (
"Duplicate eval type handler for {} with {}={!r}: {} and {}".format(
eval_type, conf_name, value, variants[value].__name__, name
)
)
variants[value] = cls
_eval_type_handlers[eval_type] = (conf_name, variants)
return cls
_iter_based: bool
@property
def is_iter_based(cls) -> bool:
"""Whether the handler's UDFs return an iterator."""
return cls._iter_based
class EvalTypeHandler(Generic[InputBatch, OutputBatch], metaclass=_EvalTypeHandlerMeta):
"""Base class for the UDF execution model.
A handler declares the ``serializer`` for the input/output streams and
implements ``run``, which consumes the input stream and yields the output
stream. Arrow handlers subclass a typed category base
(``BatchEvalTypeHandler``, ``GroupedEvalTypeHandler``,
``CoGroupedEvalTypeHandler``), which fixes the input type and serializer;
the pickled-row handlers subclass this class directly.
"""
# PythonEvalType this handler serves; None on the abstract bases.
eval_type: ClassVar[Optional[int]] = None
# Name of the RunnerConf property that selects between the handlers of one eval type, and
# the value of it this handler serves; None when the eval type has a single handler.
variant_conf: ClassVar[Optional[str]] = None
variant_value: ClassVar[Any] = None
# Whether the handler's UDFs return an iterator.
_iter_based: ClassVar[bool] = False
# Whether __init__ wraps the UDF in fail_on_stopiteration, so a StopIteration raised by
# the UDF call fails with STOP_ITERATION_OCCURRED instead of PEP 479's generic RuntimeError.
_fail_on_stopiteration: ClassVar[bool] = True
# Whether the handler takes any number of UDFs. Otherwise it takes exactly one UDF or UDTF:
# __init__ exposes a UDF as _udf_desc and its functions as _raw_func and _func, and
# UDTFHandlerBase exposes a UDTF as _udtf_desc.
_multi_udf: ClassVar[bool] = False
def __init__(
self, udf_descs: "list[UDF] | list[UDTF]", runner_conf: "RunnerConf", eval_conf: "EvalConf"
) -> None:
if not self._multi_udf and len(udf_descs) != 1:
raise PySparkRuntimeError(
"One {} expected, but got {}.".format(self._expected_udf(), len(udf_descs))
)
self._udf_descs: list[Any] = udf_descs
self._runner_conf = runner_conf
self._eval_conf = eval_conf
# Each UDF's functions chained into one before any wrapping, at the indexes of _udf_descs: a
# single function is the pickled object itself (an incremental Aggregator) with its
# signature. Empty for a UDTF.
self._raw_funcs: list[Any] = []
# Each raw function wrapped for profiling and, unless _fail_on_stopiteration is False,
# in fail_on_stopiteration, at the indexes of _udf_descs; empty for a UDTF.
self._funcs: list[Callable[..., Any]] = []
for udf in udf_descs:
if isinstance(udf, UDF):
raw_func = functools.reduce(lambda f, g: lambda *a: g(f(*a)), udf.funcs)
self._raw_funcs.append(raw_func)
profiling_func = self._with_profiler(raw_func, udf.result_id)
self._funcs.append(
fail_on_stopiteration(profiling_func)
if self._fail_on_stopiteration
else profiling_func
)
if not self._multi_udf and isinstance(udf_descs[0], UDF):
self._udf_desc: UDF = udf_descs[0]
self._raw_func: Any = self._raw_funcs[0]
self._func: Callable[..., Any] = self._funcs[0]
def _expected_udf(self) -> str:
"""What a single-UDF handler takes, as named when it is given another number of UDFs:
its eval type without the ``SQL_`` prefix and ``_UDF`` suffix, e.g. ``MAP_ARROW_ITER
UDF``."""
name = next(
(
k
for k, v in vars(PythonEvalType).items()
if k.startswith("SQL_") and v == self.eval_type
),
type(self).__name__,
)
return "{} UDF".format(name.removeprefix("SQL_").removesuffix("_UDF"))
def _with_profiler(self, f: Callable[..., Any], result_id: int) -> Callable[..., Any]:
"""Return ``f`` profiled by the profiler its config selects, or ``f`` itself when none is
set."""
# If f is from pyspark.sql.worker, it is to read/write data source.
# In this case, we check the data_source_profiler config.
module = getattr(f, "__module__", "")
if isinstance(module, str) and module.startswith("pyspark.sql.worker."):
profiler = self._runner_conf.data_source_profiler
else:
profiler = self._runner_conf.udf_profiler
if profiler == "perf":
return self._with_perf_profiler(f, result_id)
elif profiler == "memory":
return self._with_memory_profiler(f, result_id)
else:
return f
def _with_perf_profiler(self, f: Callable[..., Any], result_id: int) -> Callable[..., Any]:
from pyspark.accumulators import SpecialAccumulatorIds, _deserialize_accumulator
from pyspark.sql.profiler import (
ProfileResultsParam,
ProfileResultsParamV2,
WorkerPerfProfiler,
)
accumulator = _deserialize_accumulator(
SpecialAccumulatorIds.SQL_UDF_PROFIER, None, ProfileResultsParam
)
accumulator_v2 = _deserialize_accumulator(
SpecialAccumulatorIds.SQL_UDF_PROFIER_V2, {}, ProfileResultsParamV2
)
if self._iter_based:
def profiling_func(*args: Any, **kwargs: Any) -> Any:
# Call f eagerly, as the unprofiled path does, so fail_on_stopiteration sees a
# StopIteration raised by the call; profile each next() lazily.
iterator = iter(f(*args, **kwargs))
def profiled() -> Iterator[Any]:
while True:
try:
with WorkerPerfProfiler(accumulator, accumulator_v2, result_id):
item = next(iterator)
yield item
except StopIteration:
break
return profiled()
else:
def profiling_func(*args: Any, **kwargs: Any) -> Any:
with WorkerPerfProfiler(accumulator, accumulator_v2, result_id):
ret = f(*args, **kwargs)
return ret
return profiling_func
def _with_memory_profiler(self, f: Callable[..., Any], result_id: int) -> Callable[..., Any]:
import pyspark.memory_profiler_ext
from pyspark.accumulators import SpecialAccumulatorIds, _deserialize_accumulator
from pyspark.sql.profiler import (
ProfileResultsParam,
ProfileResultsParamV2,
WorkerMemoryProfiler,
)
if not pyspark.memory_profiler_ext.has_memory_profiler:
return f
accumulator = _deserialize_accumulator(
SpecialAccumulatorIds.SQL_UDF_PROFIER, None, ProfileResultsParam
)
accumulator_v2 = _deserialize_accumulator(
SpecialAccumulatorIds.SQL_UDF_PROFIER_V2, {}, ProfileResultsParamV2
)
if self._iter_based:
def profiling_func(*args: Any, **kwargs: Any) -> Any:
# Call f eagerly, as in the perf profiler.
g = f(*args, **kwargs)
iterator = iter(g)
def profiled() -> Iterator[Any]:
while True:
try:
with WorkerMemoryProfiler(
accumulator, accumulator_v2, result_id, g.gi_code
):
item = next(iterator)
yield item
except StopIteration:
break
return profiled()
else:
def profiling_func(*args: Any, **kwargs: Any) -> Any:
with WorkerMemoryProfiler(accumulator, accumulator_v2, result_id, f):
ret = f(*args, **kwargs)
return ret
return profiling_func
@property
@abstractmethod
def serializer(self) -> Serializer:
"""Serializer for both the input and output streams."""
@abstractmethod
def run(self, split_index: int, data: Iterator[InputBatch]) -> Iterator[OutputBatch]:
"""Consume the input stream and yield the output stream; the worker calls it as
``func(split_index, data)``."""
class BatchEvalTypeHandler(EvalTypeHandler["pa.RecordBatch", OutputBatch]):
"""Handler category whose input stream is ``Iterator[pa.RecordBatch]``."""
@property
def serializer(self) -> Serializer:
return ArrowStreamSerializer(write_start_stream=True)
class GroupedEvalTypeHandler(EvalTypeHandler[GroupedBatch, OutputBatch]):
"""Handler category whose input stream is ``Iterator[GroupedBatch]``."""
@property
def serializer(self) -> Serializer:
return ArrowStreamGroupSerializer(write_start_stream=True)
class CoGroupedEvalTypeHandler(EvalTypeHandler[CoGroupedBatch, OutputBatch]):
"""Handler category whose input stream is ``Iterator[CoGroupedBatch]``."""
@property
def serializer(self) -> Serializer:
return ArrowStreamCoGroupSerializer(write_start_stream=True)