blob: 2480e5a21e67d31e089e7f05da88675a47c7c26e [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.
#
"""Handlers for the Arrow-native UDF eval types (the UDF exchanges ``pa.Array`` /
``pa.RecordBatch`` values directly, without a pandas conversion).
pyarrow is imported lazily (inside ``run`` and the type-checking block) so the module
stays importable and its handlers register without pyarrow installed. Each handler
calls ``require_minimum_pyarrow_version`` in ``__init__``, so a missing or too-old
pyarrow surfaces a clear error when the handler runs rather than leaving the eval type
unregistered.
"""
from __future__ import annotations
import inspect
import itertools
import time
from collections.abc import Iterator
from typing import TYPE_CHECKING, Any
from pyspark.errors import PySparkRuntimeError
from pyspark.eval_handlers._base import (
BatchEvalTypeHandler,
CoGroupedEvalTypeHandler,
GroupedEvalTypeHandler,
)
from pyspark.eval_handlers.utils import (
extract_key_value_indexes,
hashable_grouping_key,
wrap_kwargs_support,
)
from pyspark.eval_handlers.verification import (
verify_iter_result_row_count,
verify_iterator_exhausted,
verify_output_row_limit,
verify_result_row_count,
verify_return_type,
verify_scalar_result,
)
from pyspark.sql.conversion import (
ArrowBatchTransformer,
ArrowTableToRowsConversion,
LocalDataToArrowConversion,
)
from pyspark.sql.pandas.serializers import ArrowStreamSerializer
from pyspark.sql.pandas.types import to_arrow_schema, to_arrow_type
from pyspark.sql.pandas.utils import require_minimum_pyarrow_version
from pyspark.sql.types import StructField, StructType
from pyspark.util import PythonEvalType, fail_on_stopiteration
from pyspark.worker_metrics import WorkerMetrics
if TYPE_CHECKING:
import pyarrow as pa
from pyspark.eval_handlers._typing import UDF, CoGroupedBatch, GroupedBatch
from pyspark.worker_util import EvalConf, RunnerConf
class ArrowCoGroupedMapUDFHandler(CoGroupedEvalTypeHandler["pa.RecordBatch"]):
"""SQL_COGROUPED_MAP_ARROW_UDF (applyInArrow on a cogroup): the single UDF
receives the two sides' value tables and returns one pa.Table, coerced to the
declared schema."""
eval_type = PythonEvalType.SQL_COGROUPED_MAP_ARROW_UDF
def __init__(self, udf_descs: list[UDF], runner_conf: RunnerConf, eval_conf: EvalConf) -> None:
require_minimum_pyarrow_version()
super().__init__(udf_descs, runner_conf, eval_conf)
# signature was lost when wrapping it
self._num_udf_args = len(inspect.getfullargspec(self._raw_func).args)
parsed_offsets = extract_key_value_indexes(self._udf_desc.args_offsets)
self._left_key_cols, self._left_val_cols = parsed_offsets[0]
self._right_key_cols, self._right_val_cols = parsed_offsets[1]
self._arrow_return_schema = to_arrow_schema(
self._udf_desc.return_type,
timezone="UTC",
prefers_large_types=runner_conf.use_large_var_types,
)
def run(self, split_index: int, data: Iterator[CoGroupedBatch]) -> Iterator[pa.RecordBatch]:
"""Apply cogroupBy Arrow UDF."""
import pyarrow as pa
select_columns = ArrowBatchTransformer.select_columns
def table_from_batches(batches: list[pa.RecordBatch], cols: list[int]) -> pa.Table:
return pa.Table.from_batches([select_columns(b, cols) for b in batches])
for left_batches, right_batches in data:
left_keys = table_from_batches(left_batches, self._left_key_cols)
left_values = table_from_batches(left_batches, self._left_val_cols)
right_keys = table_from_batches(right_batches, self._right_key_cols)
right_values = table_from_batches(right_batches, self._right_val_cols)
if self._num_udf_args == 2:
result = self._func(left_values, right_values)
else:
key_table = left_keys if left_keys.num_rows > 0 else right_keys
key = tuple(c[0] for c in key_table.columns)
result = self._func(key, left_values, right_values)
verify_return_type(result, pa.Table)
# Verify types (and reorder by name when configured).
result = ArrowBatchTransformer.enforce_schema(
result,
self._arrow_return_schema,
arrow_cast=False,
reorder_by_name=self._runner_conf.assign_cols_by_name,
)
for batch in result.to_batches():
yield ArrowBatchTransformer.wrap_struct(batch)
class ArrowGroupedMapIterUDFHandler(GroupedEvalTypeHandler["pa.RecordBatch"]):
"""SQL_GROUPED_MAP_ARROW_ITER_UDF: the single UDF receives each group as an
iterator of RecordBatches and returns an iterator of RecordBatches, coerced
to the declared schema."""
eval_type = PythonEvalType.SQL_GROUPED_MAP_ARROW_ITER_UDF
_iter_based = True
def __init__(self, udf_descs: list[UDF], runner_conf: RunnerConf, eval_conf: EvalConf) -> None:
require_minimum_pyarrow_version()
super().__init__(udf_descs, runner_conf, eval_conf)
# signature was lost when wrapping it
self._num_udf_args = len(inspect.getfullargspec(self._raw_func).args)
parsed_offsets = extract_key_value_indexes(self._udf_desc.args_offsets)
assert len(parsed_offsets) == 1, (
"Expected one pair of offsets for GROUPED_MAP_ARROW_ITER UDF."
)
self._key_offsets = parsed_offsets[0][0]
self._value_offsets = parsed_offsets[0][1]
self._arrow_return_schema = to_arrow_schema(
self._udf_desc.return_type,
timezone="UTC",
prefers_large_types=runner_conf.use_large_var_types,
)
def run(self, split_index: int, data: Iterator[GroupedBatch]) -> Iterator[pa.RecordBatch]:
"""Apply groupBy Arrow UDF (iterator variant)."""
import pyarrow as pa
key_offsets = self._key_offsets
value_offsets = self._value_offsets
for group in data:
# Flatten struct column into separate columns
flattened_iter = map(ArrowBatchTransformer.flatten_struct, group)
# Materialize first batch to get keys
first_batch = next(flattened_iter)
keys = pa.RecordBatch.from_arrays(
[first_batch.columns[o] for o in key_offsets],
[first_batch.schema.names[o] for o in key_offsets],
)
value_batches = (
pa.RecordBatch.from_arrays(
[b.columns[o] for o in value_offsets],
[b.schema.names[o] for o in value_offsets],
)
for b in itertools.chain((first_batch,), flattened_iter)
)
# Call UDF with iterator of batches
if self._num_udf_args == 1:
result = self._func(value_batches)
else:
key = tuple(c[0] for c in keys.columns)
result = self._func(key, value_batches)
# Verify (and reorder by name when configured) each output batch
for batch in verify_return_type(result, Iterator[pa.RecordBatch]):
batch = ArrowBatchTransformer.enforce_schema(
batch,
self._arrow_return_schema,
arrow_cast=False,
reorder_by_name=self._runner_conf.assign_cols_by_name,
)
yield ArrowBatchTransformer.wrap_struct(batch)
# Drain remaining input batches to maintain stream position
for _ in value_batches:
pass
class ArrowGroupedMapUDFHandler(GroupedEvalTypeHandler["pa.RecordBatch"]):
"""SQL_GROUPED_MAP_ARROW_UDF (applyInArrow): the single UDF receives each
group as one pa.Table and returns one pa.Table, coerced to the declared
schema."""
eval_type = PythonEvalType.SQL_GROUPED_MAP_ARROW_UDF
def __init__(self, udf_descs: list[UDF], runner_conf: RunnerConf, eval_conf: EvalConf) -> None:
require_minimum_pyarrow_version()
super().__init__(udf_descs, runner_conf, eval_conf)
# signature was lost when wrapping it
self._num_udf_args = len(inspect.getfullargspec(self._raw_func).args)
parsed_offsets = extract_key_value_indexes(self._udf_desc.args_offsets)
assert len(parsed_offsets) == 1, "Expected one pair of offsets for GROUPED_MAP_ARROW UDF."
self._key_offsets = parsed_offsets[0][0]
self._value_offsets = parsed_offsets[0][1]
self._arrow_return_schema = to_arrow_schema(
self._udf_desc.return_type,
timezone="UTC",
prefers_large_types=runner_conf.use_large_var_types,
)
def run(self, split_index: int, data: Iterator[GroupedBatch]) -> Iterator[pa.RecordBatch]:
"""Apply groupBy Arrow UDF (non-iterator variant)."""
import pyarrow as pa
key_offsets = self._key_offsets
value_offsets = self._value_offsets
for group in data:
# Flatten struct column into separate columns
flattened = map(ArrowBatchTransformer.flatten_struct, group)
# Materialize first batch to get keys
first_batch = next(flattened)
keys = pa.RecordBatch.from_arrays(
[first_batch.columns[o] for o in key_offsets],
[first_batch.schema.names[o] for o in key_offsets],
)
value_batches = (
pa.RecordBatch.from_arrays(
[b.columns[o] for o in value_offsets],
[b.schema.names[o] for o in value_offsets],
)
for b in itertools.chain((first_batch,), flattened)
)
# Call UDF
value_table = pa.Table.from_batches(value_batches)
if self._num_udf_args == 1:
result = self._func(value_table)
else:
key = tuple(c[0] for c in keys.columns)
result = self._func(key, value_table)
verify_return_type(result, pa.Table)
# Verify types (and reorder by name when configured).
result = ArrowBatchTransformer.enforce_schema(
result,
self._arrow_return_schema,
arrow_cast=False,
reorder_by_name=self._runner_conf.assign_cols_by_name,
)
for batch in result.to_batches():
yield ArrowBatchTransformer.wrap_struct(batch)
class ArrowGroupedAggUDFHandler(GroupedEvalTypeHandler["pa.RecordBatch"]):
"""SQL_GROUPED_AGG_ARROW_UDF: each UDF reduces its input columns over the whole
group to a single scalar; emit one row per group with one column per UDF,
coerced to the declared schema."""
eval_type = PythonEvalType.SQL_GROUPED_AGG_ARROW_UDF
_multi_udf = True
def __init__(self, udf_descs: list[UDF], runner_conf: RunnerConf, eval_conf: EvalConf) -> None:
require_minimum_pyarrow_version()
super().__init__(udf_descs, runner_conf, eval_conf)
self._col_names = ["_%d" % i for i in range(len(udf_descs))]
self._return_schema = to_arrow_schema(
StructType(
[StructField(n, udf.return_type) for n, udf in zip(self._col_names, udf_descs)]
),
timezone="UTC",
prefers_large_types=runner_conf.use_large_var_types,
)
def run(self, split_index: int, data: Iterator[GroupedBatch]) -> Iterator[pa.RecordBatch]:
import pyarrow as pa
for group in data:
concatenated = ArrowBatchTransformer.concat_batches(group)
results = [
func(
*[concatenated.column(o) for o in udf.args_offsets],
**{k: concatenated.column(v) for k, v in udf.kwargs_offsets.items()},
)
for udf, func in zip(self._udf_descs, self._funcs)
]
result_arrays = [pa.array([r]) for r in results]
batch = pa.RecordBatch.from_arrays(result_arrays, self._col_names)
yield ArrowBatchTransformer.enforce_schema(batch, self._return_schema)
class ArrowGroupedAggIterUDFHandler(GroupedEvalTypeHandler["pa.RecordBatch"]):
"""SQL_GROUPED_AGG_ARROW_ITER_UDF: the single UDF receives each group as an
iterator of its input columns and returns one scalar; emit one row per group."""
eval_type = PythonEvalType.SQL_GROUPED_AGG_ARROW_ITER_UDF
def __init__(self, udf_descs: list[UDF], runner_conf: RunnerConf, eval_conf: EvalConf) -> None:
require_minimum_pyarrow_version()
super().__init__(udf_descs, runner_conf, eval_conf)
self._return_schema = to_arrow_schema(
StructType([StructField("_0", self._udf_desc.return_type)]),
timezone="UTC",
prefers_large_types=runner_conf.use_large_var_types,
)
def run(self, split_index: int, data: Iterator[GroupedBatch]) -> Iterator[pa.RecordBatch]:
import pyarrow as pa
args_offsets = self._udf_desc.args_offsets
def extract_args(batch: pa.RecordBatch) -> Any:
args = tuple(batch.column(o) for o in args_offsets)
return args[0] if len(args) == 1 else args
for group in data:
batch_iter = map(extract_args, group)
result = self._func(batch_iter)
# Drain remaining batches to maintain stream position
for _ in batch_iter:
pass
batch = pa.RecordBatch.from_arrays([pa.array([result])], ["_0"])
yield ArrowBatchTransformer.enforce_schema(batch, self._return_schema)
class ArrowWindowAggUDFHandler(GroupedEvalTypeHandler["pa.RecordBatch"]):
"""SQL_WINDOW_AGG_ARROW_UDF: each UDF produces one value per input row over its
window frame -- an unbounded frame is computed once and repeated for every row,
a bounded frame slices the input columns per row -- emitted as one column per
UDF, coerced to the declared schema."""
eval_type = PythonEvalType.SQL_WINDOW_AGG_ARROW_UDF
_multi_udf = True
def __init__(self, udf_descs: list[UDF], runner_conf: RunnerConf, eval_conf: EvalConf) -> None:
require_minimum_pyarrow_version()
super().__init__(udf_descs, runner_conf, eval_conf)
self._window_bound_types = runner_conf.window_bound_types
self._col_names = ["_%d" % i for i in range(len(udf_descs))]
self._return_schema = to_arrow_schema(
StructType(
[StructField(n, udf.return_type) for n, udf in zip(self._col_names, udf_descs)]
),
timezone="UTC",
prefers_large_types=runner_conf.use_large_var_types,
)
def run(self, split_index: int, data: Iterator[GroupedBatch]) -> Iterator[pa.RecordBatch]:
import pyarrow as pa
for group in data:
concatenated = ArrowBatchTransformer.concat_batches(group)
num_rows = concatenated.num_rows
result_arrays = []
for udf_index, (udf, udf_func) in enumerate(zip(self._udf_descs, self._funcs)):
args_offsets, kwargs_offsets = udf.args_offsets, udf.kwargs_offsets
bound_type = self._window_bound_types[udf_index]
if bound_type == "unbounded":
result = udf_func(
*[concatenated.column(o) for o in args_offsets],
**{k: concatenated.column(v) for k, v in kwargs_offsets.items()},
)
result_arrays.append(pa.repeat(result, num_rows))
elif bound_type == "bounded":
begin_col = concatenated.column(args_offsets[0])
end_col = concatenated.column(args_offsets[1])
results = []
for i in range(num_rows):
offset = begin_col[i].as_py()
length = end_col[i].as_py() - offset
slices = [
concatenated.column(o).slice(offset=offset, length=length)
for o in args_offsets[2:]
]
kw_slices = {
k: concatenated.column(v).slice(offset=offset, length=length)
for k, v in kwargs_offsets.items()
}
results.append(udf_func(*slices, **kw_slices))
result_arrays.append(pa.array(results))
else:
raise PySparkRuntimeError(
errorClass="INVALID_WINDOW_BOUND_TYPE",
messageParameters={"window_bound_type": bound_type},
)
batch = pa.RecordBatch.from_arrays(result_arrays, self._col_names)
yield ArrowBatchTransformer.enforce_schema(batch, self._return_schema)
class ArrowGroupedAggIncrementalPartialUDFHandler(BatchEvalTypeHandler["pa.RecordBatch"]):
"""SQL_GROUPED_AGG_ARROW_INCREMENTAL_PARTIAL_UDF: map-side PARTIAL stage of an
incremental ``Aggregator``. Hash-combine input rows into a per-group buffer via
the aggregator's ``reduce`` and emit one row per key: the grouping key columns
followed by one buffer struct column per aggregator.
Ordinary (multi-group) batches are streamed in; the worker keeps one running
buffer per distinct grouping key -- never whole groups of rows. Because the FINAL
stage re-groups these authoritatively after the shuffle, the grouping here only
needs to be a best-effort combine: any keys it fails to collapse (e.g. NaN, which
compares unequal to itself) are merged downstream."""
eval_type = PythonEvalType.SQL_GROUPED_AGG_ARROW_INCREMENTAL_PARTIAL_UDF
_multi_udf = True
def __init__(self, udf_descs: list[UDF], runner_conf: RunnerConf, eval_conf: EvalConf) -> None:
require_minimum_pyarrow_version()
super().__init__(udf_descs, runner_conf, eval_conf)
# The leading `num_grouping_keys` input columns are the grouping keys (see the operator);
# `grouping_key_schema` carries their names/types so the emitted key columns round-trip.
grouping_key_schema = eval_conf.grouping_key_schema
self._num_grouping_keys = (
len(grouping_key_schema.fields) if grouping_key_schema is not None else 0
)
# The pickled function is the Aggregator object itself, whose zero/reduce/merge/finish
# methods are called directly, so take it unwrapped rather than as a plain callable.
self._aggs = self._raw_funcs
self._buffer_col_names = ["_%d" % i for i in range(len(udf_descs))]
self._buffer_arrow_types = [
to_arrow_type(
agg.bufferSchema,
timezone="UTC",
prefers_large_types=runner_conf.use_large_var_types,
)
for agg in self._aggs
]
# Buffer field names are invariant across groups; compute them once per aggregator.
self._field_names_by_udf = [[f.name for f in agg.bufferSchema.fields] for agg in self._aggs]
# The aggregator's `reduce` receives a single positional tuple, so any named arguments at
# the call site are appended after the positional ones, in call order (kwargs_offsets
# preserves that order). This mirrors how a Python call `f(*args, **kwargs)` would order
# them into one value tuple.
self._input_offsets_by_udf = [
list(udf.args_offsets) + list(udf.kwargs_offsets.values()) for udf in udf_descs
]
# Input columns actually consumed per batch: the leading grouping keys plus every
# aggregator input, deduplicated. A UDF input may reuse a grouping-key column (the operator
# dedups its projection), so converting by distinct offset avoids repeated `to_pylist()`.
self._needed_offsets = sorted(
set(range(self._num_grouping_keys))
| {o for offsets in self._input_offsets_by_udf for o in offsets}
)
# Cap the map-side buffer so a high-cardinality partition -- exactly where partial
# aggregation degenerates -- cannot grow the per-key dict without bound and OOM the worker.
# When the distinct-key count reaches the cap we flush the whole map as one batch and start
# fresh; end-of-partition buffers are emitted in equally bounded chunks. This is safe
# because the FINAL stage re-groups the emitted partial buffers authoritatively after the
# shuffle and merges any duplicate keys that early flushes produce. A non-positive
# maxRecordsPerBatch means "unbounded" (mirroring the reader side).
max_records = runner_conf.arrow_max_records_per_batch
self._cap = max_records if max_records > 0 else None
def run(self, split_index: int, data: Iterator[pa.RecordBatch]) -> Iterator[pa.RecordBatch]:
import pyarrow as pa
num_grouping_keys = self._num_grouping_keys
cap = self._cap
# hashable key -> (representative key value tuple, list of per-aggregator buffers)
groups: dict[Any, tuple] = {}
key_field_types: list | None = None
def make_batch(entries: list) -> pa.RecordBatch:
arrays = []
names = []
for j in range(num_grouping_keys):
arrays.append(
pa.array(
[e[0][j] for e in entries],
type=key_field_types[j], # type: ignore[index]
)
)
names.append("k_%d" % j)
for i, field_names in enumerate(self._field_names_by_udf):
structs = [
{name: e[1][i][t] for t, name in enumerate(field_names)} for e in entries
]
arrays.append(pa.array(structs, type=self._buffer_arrow_types[i]))
names.append(self._buffer_col_names[i])
return pa.RecordBatch.from_arrays(arrays, names)
for batch in data:
if key_field_types is None:
key_field_types = [batch.schema.field(j).type for j in range(num_grouping_keys)]
pylist_by_offset = {o: batch.column(o).to_pylist() for o in self._needed_offsets}
key_cols = [pylist_by_offset[j] for j in range(num_grouping_keys)]
udf_cols = [
[pylist_by_offset[o] for o in offsets] for offsets in self._input_offsets_by_udf
]
for r in range(batch.num_rows):
key_values = tuple(key_cols[j][r] for j in range(num_grouping_keys))
hashable_key = hashable_grouping_key(key_values)
entry = groups.get(hashable_key)
if entry is None:
buffers = [agg.zero() for agg in self._aggs]
groups[hashable_key] = (key_values, buffers)
else:
buffers = entry[1]
for i, agg in enumerate(self._aggs):
cols_i = udf_cols[i]
buffers[i] = agg.reduce(buffers[i], tuple(c[r] for c in cols_i))
if cap is not None and len(groups) >= cap:
yield make_batch(list(groups.values()))
groups = {}
if not groups:
# Empty partition (or fully flushed above): emit nothing more. The FINAL stage
# supplies the global-aggregation identity row when there is no input at all.
return
entries = list(groups.values())
if cap is None:
yield make_batch(entries)
else:
for start in range(0, len(entries), cap):
yield make_batch(entries[start : start + cap])
class ArrowGroupedAggIncrementalFinalUDFHandler(GroupedEvalTypeHandler["pa.RecordBatch"]):
"""SQL_GROUPED_AGG_ARROW_INCREMENTAL_FINAL_UDF: post-shuffle FINAL stage of an
incremental ``Aggregator``. Merge each group's partial buffers via the aggregator's
``merge`` and produce the output via ``finish``, streaming the buffers one batch at
a time.
Every group the JVM sends yields exactly one output row; null partial-buffer rows
are skipped, so an empty global aggregation (a single all-null buffer row injected
by the operator) still produces ``finish(zero)``."""
eval_type = PythonEvalType.SQL_GROUPED_AGG_ARROW_INCREMENTAL_FINAL_UDF
_multi_udf = True
def __init__(self, udf_descs: list[UDF], runner_conf: RunnerConf, eval_conf: EvalConf) -> None:
require_minimum_pyarrow_version()
super().__init__(udf_descs, runner_conf, eval_conf)
self._col_names = ["_%d" % i for i in range(len(udf_descs))]
self._return_schema = to_arrow_schema(
StructType(
[StructField(n, udf.return_type) for n, udf in zip(self._col_names, udf_descs)]
),
timezone="UTC",
prefers_large_types=runner_conf.use_large_var_types,
)
# The Aggregator object itself, unwrapped (see ArrowGroupedAggIncrementalPartialUDFHandler).
self._aggs = self._raw_funcs
# Buffer field names are invariant across groups and batches; compute once per aggregator.
self._field_names_by_udf = [[f.name for f in agg.bufferSchema.fields] for agg in self._aggs]
def run(self, split_index: int, data: Iterator[GroupedBatch]) -> Iterator[pa.RecordBatch]:
import pyarrow as pa
for group in data:
merged: list = [None] * len(self._aggs)
for batch in group:
for i, (udf, agg) in enumerate(zip(self._udf_descs, self._aggs)):
field_names = self._field_names_by_udf[i]
m = merged[i]
for row in batch.column(udf.args_offsets[0]).to_pylist():
if row is None:
continue
partial = tuple(row[name] for name in field_names)
m = partial if m is None else agg.merge(m, partial)
merged[i] = m
results = []
for i, agg in enumerate(self._aggs):
m = merged[i] if merged[i] is not None else agg.zero()
results.append(agg.finish(m))
# Type each output array explicitly (mirroring the PARTIAL stage) so a non-trivial
# outputType or an all-None column does not depend on Arrow type inference.
result_arrays = [
pa.array([r], type=self._return_schema.field(i).type) for i, r in enumerate(results)
]
batch = pa.RecordBatch.from_arrays(result_arrays, self._col_names)
yield ArrowBatchTransformer.enforce_schema(batch, self._return_schema)
class ArrowWindowAggIncrementalUDFHandler(GroupedEvalTypeHandler["pa.RecordBatch"]):
"""SQL_WINDOW_AGG_ARROW_INCREMENTAL_UDF: window aggregation with an incremental
``Aggregator``. The operator sends each frame -- the whole partition for an
unbounded frame, or per-row ``[begin, end)`` slices for a bounded one -- and each
frame's rows are folded with ``reduce`` (from a fresh ``zero``) and finished with
``finish``, one output value per input row. A window has no shuffle, so the
intermediate buffer never leaves the worker; ``merge`` is not used here."""
eval_type = PythonEvalType.SQL_WINDOW_AGG_ARROW_INCREMENTAL_UDF
_multi_udf = True
def __init__(self, udf_descs: list[UDF], runner_conf: RunnerConf, eval_conf: EvalConf) -> None:
require_minimum_pyarrow_version()
super().__init__(udf_descs, runner_conf, eval_conf)
self._window_bound_types = runner_conf.window_bound_types
self._col_names = ["_%d" % i for i in range(len(udf_descs))]
self._return_schema = to_arrow_schema(
StructType(
[StructField(n, udf.return_type) for n, udf in zip(self._col_names, udf_descs)]
),
timezone="UTC",
prefers_large_types=runner_conf.use_large_var_types,
)
# The Aggregator object itself, unwrapped (see ArrowGroupedAggIncrementalPartialUDFHandler).
self._aggs = self._raw_funcs
def run(self, split_index: int, data: Iterator[GroupedBatch]) -> Iterator[pa.RecordBatch]:
import pyarrow as pa
def fold(agg: Any, buffer: Any, value_cols: list, start: int, end: int) -> Any:
# Fold rows ``[start, end)`` (each a tuple across ``value_cols``, matching the
# call-site argument order) into ``buffer`` via the aggregator's ``reduce``.
for r in range(start, end):
buffer = agg.reduce(buffer, tuple(c[r] for c in value_cols))
return buffer
for group in data:
concatenated = ArrowBatchTransformer.concat_batches(group)
num_rows = concatenated.num_rows
result_arrays = []
for udf_index, (udf, agg) in enumerate(zip(self._udf_descs, self._aggs)):
args_offsets, kwargs_offsets = udf.args_offsets, udf.kwargs_offsets
bound_type = self._window_bound_types[udf_index]
result_type = self._return_schema.field(udf_index).type
if bound_type == "unbounded":
# One frame spanning the whole partition: compute once, repeat per row.
value_cols = [concatenated.column(o).to_pylist() for o in args_offsets] + [
concatenated.column(v).to_pylist() for v in kwargs_offsets.values()
]
result = agg.finish(fold(agg, agg.zero(), value_cols, 0, num_rows))
result_arrays.append(pa.array([result] * num_rows, type=result_type))
elif bound_type == "bounded":
# Per-row frame ``[begin, end)``. Materialize the aggregator's input columns
# once; frames index into them by row.
begin_col = concatenated.column(args_offsets[0])
end_col = concatenated.column(args_offsets[1])
data_offsets = list(args_offsets[2:]) + list(kwargs_offsets.values())
value_cols = [concatenated.column(o).to_pylist() for o in data_offsets]
# When consecutive frames share the same lower bound and only grow on the
# right (e.g. rowsBetween(unboundedPreceding, currentRow)), extend the
# running buffer by the newly-included rows instead of refolding from
# ``zero`` -- O(n) overall rather than O(n^2). Otherwise -- the lower bound
# advanced (a row left the window, which ``reduce`` cannot subtract) or the
# frame shrank -- refold the frame from ``zero``.
results = []
have_running = False
running: Any = None
prev_begin = -1
prev_end = 0
for i in range(num_rows):
begin = begin_col[i].as_py()
end = end_col[i].as_py()
if have_running and begin == prev_begin and end >= prev_end:
running = fold(agg, running, value_cols, prev_end, end)
else:
running = fold(agg, agg.zero(), value_cols, begin, end)
have_running = True
prev_begin, prev_end = begin, end
results.append(agg.finish(running))
result_arrays.append(pa.array(results, type=result_type))
else:
raise PySparkRuntimeError(
errorClass="INVALID_WINDOW_BOUND_TYPE",
messageParameters={"window_bound_type": bound_type},
)
batch = pa.RecordBatch.from_arrays(result_arrays, self._col_names)
yield ArrowBatchTransformer.enforce_schema(batch, self._return_schema)
class ArrowMapUDFHandler(BatchEvalTypeHandler["pa.RecordBatch"]):
"""SQL_MAP_ARROW_ITER_UDF (mapInArrow): the single UDF receives the input
RecordBatch stream and yields a RecordBatch stream, exchanged as flattened
columns on the wire and wrapped back into a single struct column."""
eval_type = PythonEvalType.SQL_MAP_ARROW_ITER_UDF
_iter_based = True
def __init__(self, udf_descs: list[UDF], runner_conf: RunnerConf, eval_conf: EvalConf) -> None:
require_minimum_pyarrow_version()
super().__init__(udf_descs, runner_conf, eval_conf)
def run(self, split_index: int, data: Iterator[pa.RecordBatch]) -> Iterator[pa.RecordBatch]:
import pyarrow as pa
# Pre-processing
input_batches = map(ArrowBatchTransformer.flatten_struct, data)
# invoke the UDF
output_batches = self._func(input_batches)
# The declared signature is Iterator[...], so a strict iterator is required by
# default. With the legacy flag, accept any object Python can iterate over -- via
# iter(...), which honors both __iter__ and the sequence protocol (__getitem__) --
# by adapting it into an iterator before the shared element-type verification.
if self._runner_conf.map_in_batch_legacy_accept_any_iterable and not isinstance(
output_batches, Iterator
):
try:
output_batches = iter(output_batches)
except TypeError:
# Not iterable at all; leave it so verify_return_type below raises the
# standard UDF_RETURN_TYPE error.
pass
# Post-processing
verified_iter = verify_return_type(output_batches, Iterator[pa.RecordBatch])
yield from map(ArrowBatchTransformer.wrap_struct, verified_iter)
class ArrowScalarIterUDFHandler(BatchEvalTypeHandler["pa.RecordBatch"]):
"""SQL_SCALAR_ARROW_ITER_UDF: the UDF receives an iterator of the argument
columns and yields an iterator of pa.Array; enforce the declared type on each
result and verify the total row count matches the input."""
eval_type = PythonEvalType.SQL_SCALAR_ARROW_ITER_UDF
_iter_based = True
def __init__(self, udf_descs: list[UDF], runner_conf: RunnerConf, eval_conf: EvalConf) -> None:
require_minimum_pyarrow_version()
super().__init__(udf_descs, runner_conf, eval_conf)
self._arrow_return_type = to_arrow_type(
self._udf_desc.return_type,
timezone="UTC",
prefers_large_types=runner_conf.use_large_var_types,
)
def run(self, split_index: int, data: Iterator[pa.RecordBatch]) -> Iterator[pa.RecordBatch]:
import pyarrow as pa
args_offsets = self._udf_desc.args_offsets
num_input_rows = 0
def extract_args(batch: pa.RecordBatch) -> Any:
nonlocal num_input_rows
args = tuple(batch.column(o) for o in args_offsets)
num_input_rows += batch.num_rows
return args[0] if len(args) == 1 else args
# Extract args from input batches (streaming)
args_iter = map(extract_args, data)
# Call UDF and verify result type (iterator of pa.Array)
verified_iter = verify_return_type(self._func(args_iter), Iterator[pa.Array])
# Process results: enforce schema and assemble into RecordBatch
target_schema = pa.schema([pa.field("_0", self._arrow_return_type)])
def process_results() -> Iterator[pa.RecordBatch]:
for result in verified_iter:
batch = pa.RecordBatch.from_arrays([result], ["_0"])
yield ArrowBatchTransformer.enforce_schema(batch, target_schema, safecheck=True)
# Apply row limit check (fail-fast)
limited = verify_output_row_limit(process_results(), lambda: num_input_rows)
# Apply row count match check (final)
matched = verify_iter_result_row_count(limited, lambda: num_input_rows)
# Yield batches
yield from matched
# Verify iterator consumed
verify_iterator_exhausted(args_iter)
class ArrowScalarUDFHandler(BatchEvalTypeHandler["pa.RecordBatch"]):
"""SQL_SCALAR_ARROW_UDF: invoke each UDF once per input RecordBatch, coerce
the result to the declared schema, and check the row count."""
eval_type = PythonEvalType.SQL_SCALAR_ARROW_UDF
_multi_udf = True
def __init__(self, udf_descs: list[UDF], runner_conf: RunnerConf, eval_conf: EvalConf) -> None:
require_minimum_pyarrow_version()
super().__init__(udf_descs, runner_conf, eval_conf)
self._col_names = ["_%d" % i for i in range(len(udf_descs))]
self._combined_arrow_schema = to_arrow_schema(
StructType(
[StructField(n, udf.return_type) for n, udf in zip(self._col_names, udf_descs)]
),
timezone="UTC",
prefers_large_types=runner_conf.use_large_var_types,
)
def run(self, split_index: int, data: Iterator[pa.RecordBatch]) -> Iterator[pa.RecordBatch]:
import pyarrow as pa
for batch in data:
output_batch = pa.RecordBatch.from_arrays(
[
func(
*[batch.column(o) for o in udf.args_offsets],
**{k: batch.column(v) for k, v in udf.kwargs_offsets.items()},
)
for udf, func in zip(self._udf_descs, self._funcs)
],
self._col_names,
)
output_batch = ArrowBatchTransformer.enforce_schema(
output_batch, self._combined_arrow_schema
)
verify_scalar_result(output_batch, batch.num_rows)
yield output_batch
class ArrowBatchedUDFArrowHandler(BatchEvalTypeHandler["pa.RecordBatch"]):
"""SQL_ARROW_BATCHED_UDF (Arrow-optimized ``@udf``) with the Arrow conversion: convert each
input RecordBatch to Python values, invoke each UDF row by row, and convert the results
directly to Arrow arrays of the declared return types."""
eval_type = PythonEvalType.SQL_ARROW_BATCHED_UDF
variant_conf = "use_legacy_pandas_udf_conversion"
variant_value = False
_multi_udf = True
# run wraps the row-by-row evaluation in fail_on_stopiteration itself.
_fail_on_stopiteration = False
def __init__(self, udf_descs: list[UDF], runner_conf: RunnerConf, eval_conf: EvalConf) -> None:
require_minimum_pyarrow_version()
super().__init__(udf_descs, runner_conf, eval_conf)
# --- UDF preparation ---
self._udf_infos = []
for udf, func in zip(udf_descs, self._funcs):
wrapped_func, args_kwargs_offsets = wrap_kwargs_support(
func, udf.args_offsets, udf.kwargs_offsets
)
self._udf_infos.append(
(
wrapped_func,
args_kwargs_offsets,
to_arrow_type(
udf.return_type,
timezone="UTC",
prefers_large_types=runner_conf.use_large_var_types,
),
LocalDataToArrowConversion._create_converter(
udf.return_type,
none_on_identity=True,
int_to_decimal_coercion_enabled=runner_conf.int_to_decimal_coercion_enabled,
),
)
)
assert self._udf_infos, "At least one Arrow UDF expected here."
self._col_names = [f"_{i}" for i in range(len(udf_descs))]
# --- Input preparation ---
self._arrow_to_py_converters = [
ArrowTableToRowsConversion._create_converter(
f.dataType, none_on_identity=True, binary_as_bytes=runner_conf.binary_as_bytes
)
for f in eval_conf.input_type # type: ignore[union-attr]
]
@property
def serializer(self) -> ArrowStreamSerializer:
return ArrowStreamSerializer(
write_start_stream=True,
collect_timing=self._runner_conf.detailed_timing_enabled,
)
def run(self, split_index: int, data: Iterator[pa.RecordBatch]) -> Iterator[pa.RecordBatch]:
import pyarrow as pa
runner_conf = self._runner_conf
metrics = WorkerMetrics()
if runner_conf.detailed_timing_enabled:
phase_timers = (
metrics.measure("pythonInputPreparationTime"),
metrics.measure("pythonUDFExecutionTime"),
metrics.measure("pythonOutputPreparationTime"),
)
metrics.set("pythonNumTimingReports", 1)
metrics.set("pythonNumTimedBatches", 0)
else:
phase_timers = None
@fail_on_stopiteration
def _evaluate_batch_udf(udf_func: Any, args: list[list[Any]], num_rows: int) -> list[Any]:
if runner_conf.arrow_concurrency_level <= 0:
if not args:
return [udf_func() for _ in range(num_rows)]
# Call the UDF in the comprehension body, not via list(map(...)): map()
# would swallow a StopIteration raised by the UDF as end of input.
if len(args) == 1:
return [udf_func(v) for v in args[0]]
return [udf_func(*row) for row in zip(*args)]
from concurrent.futures import ThreadPoolExecutor
with ThreadPoolExecutor(max_workers=runner_conf.arrow_concurrency_level) as pool:
if not args:
return list(pool.map(lambda _: udf_func(), range(num_rows)))
return list(pool.map(udf_func, *args))
# Keep phase totals and the batch count local, then publish them once per task.
input_duration_ns = 0
udf_duration_ns = 0
output_duration_ns = 0
num_timed_batches = 0
clock = time.perf_counter_ns
num_udfs = len(self._udf_infos)
try:
for input_batch in data:
if phase_timers is not None:
num_timed_batches += 1
input_start_ns = clock()
num_rows = input_batch.num_rows
# --- Input: Arrow -> Python columns ---
columns = [
(
[conv(v) for v in ArrowTableToRowsConversion._to_pylist(col)]
if conv is not None
else ArrowTableToRowsConversion._to_pylist(col)
)
for col, conv in zip(input_batch.itercolumns(), self._arrow_to_py_converters)
]
# --- Process: evaluate each UDF row-by-row ---
output_arrays: list[pa.Array] = []
for udf_func, offsets, arrow_return_type, result_conv in self._udf_infos:
if phase_timers is not None and output_arrays:
# Reuse the batch start for the first UDF, avoiding another clock read.
# Later UDFs start input after the loop transition.
input_start_ns = clock()
args = [columns[o] for o in offsets]
if phase_timers is not None:
udf_start_ns = clock()
input_duration_ns += udf_start_ns - input_start_ns
results = _evaluate_batch_udf(udf_func, args, num_rows)
if phase_timers is not None:
output_start_ns = clock()
udf_duration_ns += output_start_ns - udf_start_ns
verify_result_row_count(len(results), num_rows)
# --- Output: Python -> Arrow ---
converted = (
[result_conv(r) for r in results] if result_conv is not None else results
)
try:
arr = pa.array(converted, type=arrow_return_type)
except pa.lib.ArrowInvalid:
arr = pa.array(converted).cast(
target_type=arrow_return_type, safe=runner_conf.safecheck
)
output_arrays.append(arr)
# All UDF results for this batch are ready; time its assembly as output.
if len(output_arrays) == num_udfs:
output_batch = pa.RecordBatch.from_arrays(output_arrays, self._col_names)
if phase_timers is not None:
output_duration_ns += clock() - output_start_ns
yield output_batch
del output_batch
finally:
# Publish the count and completed intervals if the generator closes early or raises.
if phase_timers is not None:
metrics.set("pythonNumTimedBatches", num_timed_batches)
input_timer, udf_timer, output_timer = phase_timers
input_timer.add_duration_ns(input_duration_ns)
udf_timer.add_duration_ns(udf_duration_ns)
output_timer.add_duration_ns(output_duration_ns)