Skip to content

Commit 4eb8666

Browse files
authored
[FLINK-40472][python] Support arrow vectorized UDFs in DataFrame API (#29210)
1 parent 345f99c commit 4eb8666

28 files changed

Lines changed: 1432 additions & 246 deletions

File tree

flink-python/docs/reference/pyflink.dataframe/udf.rst

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,9 @@ columns. A scalar UDF produces one logical output column and can be used in
2626
:meth:`~pyflink.dataframe.DataFrame.with_columns`, and
2727
:meth:`~pyflink.dataframe.DataFrame.select`.
2828

29-
DataFrame scalar UDFs support synchronous, asynchronous, and pandas-vectorized
30-
callables. See :func:`pyflink.dataframe.udf` for declaration forms, type
31-
inference, execution modes, and examples.
29+
DataFrame scalar UDFs support general synchronous and asynchronous callables,
30+
and synchronous pandas or Arrow vectorized callables. See :func:`pyflink.dataframe.udf`
31+
for declaration forms, type inference, execution modes, and examples.
3232

3333
API Reference
3434
=============

flink-python/pyflink/dataframe/tests/test_udf.py

Lines changed: 129 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,86 @@ def _call_module_alias_function(value):
6161

6262

6363
class DataFrameUDFDeclarationTests(unittest.TestCase):
64+
def test_arrow_annotation_inference_and_overrides(self):
65+
def arrow_identity(values: pa.Array) -> pa.ChunkedArray:
66+
return pa.chunked_array([values])
67+
68+
def integer_array(values: pa.Int64Array) -> pa.Int64Array:
69+
return values
70+
71+
def list_array(values: pa.ListArray):
72+
return values.flatten()
73+
74+
def struct_array(values: "pa.StructArray"):
75+
return values.field("value")
76+
77+
def concrete_return(values) -> pa.Int64Array:
78+
return values
79+
80+
def mixed(values: pd.Series) -> pa.Int64Array:
81+
return pa.array(values)
82+
83+
def captured(context: pd.Series, values: pa.Array) -> pa.Array:
84+
return values
85+
86+
class ArrowCallable:
87+
def __call__(self, values: "pa.Array") -> "pa.Array":
88+
return values
89+
90+
class ArrowScalar(ScalarFunction):
91+
def eval(self, values: pa.Array) -> pa.Array:
92+
return values
93+
94+
for func in (arrow_identity, integer_array, list_array, struct_array, concrete_return,
95+
ArrowCallable, ArrowCallable(), ArrowScalar, ArrowScalar(),
96+
functools.partial(captured, pd.Series([1]))):
97+
with self.subTest(func=func):
98+
declaration = pf.udf(func, return_dtype=pf.DataType.int64())
99+
self.assertEqual(cast(Any, declaration)._func_type, "arrow")
100+
101+
for mode in ("general", "pandas", "arrow"):
102+
with self.subTest(mode=mode):
103+
declaration = pf.udf(mixed, return_dtype=pf.DataType.int64(), func_type=mode)
104+
self.assertEqual(cast(Any, declaration)._func_type, mode)
105+
106+
with self.assertRaisesRegex(ValueError, "pandas.*Arrow.*func_type"):
107+
pf.udf(mixed, return_dtype=pf.DataType.int64())
108+
with self.assertRaisesRegex(TypeError, "return_dtype is required for arrow"):
109+
pf.udf(arrow_identity)
110+
111+
def test_explicit_arrow_declarations(self):
112+
from pyflink.table.udf import udf as table_udf
113+
114+
def identity(values):
115+
return values
116+
117+
declaration = pf.udf(identity, return_dtype=pf.DataType.string(), func_type="arrow")
118+
self.assertEqual(_return_dtype(declaration), pf.DataType.string())
119+
table_udf(identity, result_type=TableDataTypes.STRING(), func_type="arrow")
120+
121+
with self.assertRaisesRegex(TypeError, "return_dtype is required for arrow"):
122+
pf.udf(identity, func_type="arrow")
123+
124+
async def async_identity(values):
125+
return values
126+
127+
class AsyncCallable:
128+
async def __call__(self, values):
129+
return values
130+
131+
for declare in (
132+
lambda: pf.udf(async_identity, return_dtype=pf.DataType.string(), func_type="arrow"),
133+
lambda: table_udf(async_identity, result_type=TableDataTypes.STRING(),
134+
func_type="arrow"),
135+
lambda: table_udf(AsyncCallable(), result_type=TableDataTypes.STRING(),
136+
func_type="arrow"),
137+
lambda: table_udf(functools.partial(AsyncCallable()),
138+
result_type=TableDataTypes.STRING(), func_type="arrow"),
139+
):
140+
with self.subTest(declare=declare):
141+
with self.assertRaisesRegex(ValueError, "Async.*arrow"):
142+
declare()
143+
64144
def test_function_declarations_return_types_and_metadata(self):
65145
class Details(TypedDict):
66146
label: str
@@ -547,9 +627,9 @@ def __call__(self, context: pd.Series, value: int) -> int:
547627
False,
548628
),
549629
(
550-
"pyarrow annotations remain general",
630+
"inferred arrow",
551631
lambda: pf.udf(arrow_add_one, return_dtype=pf.DataType.int64()),
552-
"general",
632+
"arrow",
553633
False,
554634
),
555635
(
@@ -1178,11 +1258,11 @@ def eval(self, value):
11781258
"name must not be empty",
11791259
),
11801260
(
1181-
"arrow func type",
1261+
"unsupported func type",
11821262
lambda: pf.udf(
11831263
missing_return,
11841264
return_dtype=pf.DataType.int64(),
1185-
func_type="arrow",
1265+
func_type="unsupported",
11861266
),
11871267
ValueError,
11881268
"func_type must be one of",
@@ -1480,6 +1560,21 @@ def close(self):
14801560

14811561

14821562
class DataFrameUDFPlannerTests(PyFlinkDataFrameUTTestCase):
1563+
def test_arrow_calls_require_a_column_argument(self):
1564+
from pyflink.table import ExplainDetail
1565+
1566+
@pf.udf(return_dtype=pf.DataType.int64(), func_type="arrow")
1567+
def identity(*values):
1568+
return values[0]
1569+
1570+
dataframe = pf.from_records([(1,)], schema=["id"])
1571+
for args in ((), (1,), (pf.lit(1),), (identity(),)):
1572+
with self.subTest(args=args):
1573+
with self.assertRaisesRegex(Exception, "at least one column-valued argument"):
1574+
result = dataframe.with_columns(
1575+
valid=identity(pf.col("id")), invalid=identity(*args))
1576+
result.to_table().explain(ExplainDetail.JSON_EXECUTION_PLAN)
1577+
14831578
def test_with_columns_binds_expressions_and_resolves_output_schema(self):
14841579
@pf.udf(name="render_value")
14851580
def render(value: int, suffix: str) -> str:
@@ -1521,6 +1616,25 @@ def describe(value):
15211616

15221617
class DataFrameUDFITCase(PyFlinkStreamDataFrameTestCase):
15231618
def test_supported_scalar_udfs_in_one_job(self):
1619+
import pyarrow.compute as pc
1620+
1621+
self.env.set_parallelism(1)
1622+
self.t_env.get_config().set("python.fn-execution.bundle.size", "3")
1623+
self.t_env.get_config().set("python.fn-execution.arrow.batch.size", "2")
1624+
1625+
@pf.udf(return_dtype=pf.DataType.string())
1626+
def normalize_name(names: pa.Array) -> pa.Array:
1627+
return pc.utf8_upper(names)
1628+
1629+
@pf.udf(return_dtype=pf.DataType.struct({"value": pf.DataType.int64().not_null()}))
1630+
def describe(values: pa.Array) -> pa.ChunkedArray:
1631+
result = pa.StructArray.from_arrays([pc.multiply(values, 2)], names=["value"])
1632+
return pa.chunked_array([result.slice(0, 1), result.slice(1)])
1633+
1634+
@pf.udf(return_dtype=pf.DataType.int64())
1635+
def struct_value(values: pa.Array) -> pa.Array:
1636+
return pc.struct_field(values, "value")
1637+
15241638
@dataclass
15251639
class Details:
15261640
doubled: int
@@ -1561,19 +1675,27 @@ def eval(self, *values: int) -> int:
15611675
opened_scalar_class = pf.udf(OpenedScalarFunction)
15621676

15631677
result = (
1564-
pf.from_records([(1,)], schema=["id"])
1678+
pf.from_records([(1, "alice"), (2, None), (3, "Bob")], schema=["id", "name"])
15651679
.with_columns(async_value=add_two(pf.col("id")))
15661680
.with_columns(
15671681
pandas_value=add_three(pf.col("id")),
15681682
details=details(pf.col("id")),
15691683
deferred_value=deferred(pf.col("id")),
15701684
scalar_value=opened_scalar_class(pf.col("id")),
1685+
normalized_name=normalize_name(pf.col("name")),
1686+
arrow_details=describe(pf.col("id")),
1687+
arrow_after_pandas=struct_value(describe(add_three(pf.col("id")))),
1688+
pandas_after_arrow=add_three(struct_value(describe(pf.col("id")))),
15711689
)
15721690
)
15731691

15741692
self.assertEqual(
1575-
result.collect(),
1576-
[Row(1, 3, 4, Row(2, ["1"]), 5, 6)],
1693+
sorted(result.collect(), key=lambda row: row[0]),
1694+
[
1695+
Row(1, "alice", 3, 4, Row(2, ["1"]), 5, 6, "ALICE", Row(2), 8, 5),
1696+
Row(2, None, 4, 5, Row(4, ["2"]), 6, 7, None, Row(4), 10, 7),
1697+
Row(3, "Bob", 5, 6, Row(6, ["3"]), 7, 8, "BOB", Row(6), 12, 9),
1698+
],
15771699
)
15781700

15791701

flink-python/pyflink/dataframe/udf.py

Lines changed: 57 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -410,6 +410,27 @@ def udf(
410410
... def inferred_pandas_add_one(values: pd.Series) -> pd.Series:
411411
... return values + 1
412412
413+
Arrow UDFs always require an explicit logical ``return_dtype`` and support
414+
synchronous functions. Each column argument is received as a ``pyarrow.Array``;
415+
a ``ROW``-typed column is received as a ``pyarrow.StructArray`` with one child
416+
array per field. Results should be returned as a ``pyarrow.Array`` or
417+
``pyarrow.ChunkedArray`` of the declared logical type, with the same number of
418+
rows as the input batch. A ``ROW``-typed result uses a ``pyarrow.StructArray``
419+
or a chunked array of structs. Arrow mode can be selected explicitly, or
420+
inferred from an Arrow container annotation on any unbound parameter or the
421+
return value::
422+
423+
>>> import pyarrow as pa
424+
>>> import pyarrow.compute as pc
425+
426+
>>> @pf.udf(return_dtype=pf.DataType.int64(), func_type="arrow")
427+
... def arrow_add_one(values):
428+
... return pc.add(values, 1)
429+
430+
>>> @pf.udf(return_dtype=pf.DataType.string())
431+
... def normalize_name(names: pa.Array) -> pa.Array:
432+
... return pc.utf8_upper(names)
433+
413434
A declared UDF is called with DataFrame expressions or Python literals to
414435
produce a single-column expression::
415436
@@ -424,12 +445,13 @@ def udf(
424445
callable/scalar-UDF class.
425446
:param return_dtype: DataFrame logical type, Python type, or SQL type string.
426447
General UDFs may infer it from a return annotation;
427-
pandas UDFs require it.
448+
pandas and Arrow UDFs require it.
428449
:param deterministic: Whether equal inputs always produce equal results.
429450
Must agree with scalar-function metadata.
430451
:param name: Non-empty function identity used by the Table planner.
431-
:param func_type: ``"general"`` or ``"pandas"``. If omitted, any unbound
432-
pandas container annotation selects pandas mode.
452+
:param func_type: ``"general"``, ``"pandas"``, or ``"arrow"``. If omitted,
453+
unbound container annotations select pandas or Arrow mode;
454+
otherwise general mode is used.
433455
:return: A callable that accepts DataFrame expressions or Python literals and
434456
returns an :class:`~pyflink.table.expression.Expression`, or a decorator
435457
producing such a callable when ``func`` is omitted.
@@ -487,18 +509,18 @@ def _validate_scalar_udf_options(
487509
return_dtype: Optional[_DataTypeLike],
488510
is_async: bool,
489511
) -> None:
490-
if func_type not in ("general", "pandas"):
512+
if func_type not in ("general", "pandas", "arrow"):
491513
raise ValueError(
492-
f"The func_type must be one of 'general, pandas', got {func_type}."
514+
f"The func_type must be one of 'general, pandas, arrow', got {func_type}."
493515
)
494-
if return_dtype is None and func_type == "pandas":
516+
if return_dtype is None and func_type in ("pandas", "arrow"):
495517
raise TypeError(
496-
"return_dtype is required for pandas UDFs because pandas container "
518+
f"return_dtype is required for {func_type} UDFs because {func_type} container "
497519
"annotations do not describe the logical result type."
498520
)
499-
if is_async and func_type == "pandas":
521+
if is_async and func_type in ("pandas", "arrow"):
500522
raise ValueError(
501-
"Async scalar functions do not support pandas func_type. "
523+
f"Async scalar functions do not support {func_type} func_type. "
502524
"Use func_type='general'."
503525
)
504526

@@ -1003,30 +1025,44 @@ def _data_type_from_type_hint(type_hint: Any) -> DataType:
10031025

10041026

10051027
def _detect_func_type(declaration_context: _UDFDeclarationContext) -> str:
1006-
"""Detect pandas mode from an unbound pandas container annotation."""
1028+
"""Detect a unique vectorized mode from unbound container annotations."""
10071029
hint_func = declaration_context.annotation_target
1030+
container_types: Dict[str, Tuple[Type, ...]] = {}
1031+
container_globalns: Dict[str, Any] = {}
10081032
try:
10091033
import pandas as pd
1034+
container_types["pandas"] = (pd.Series, pd.DataFrame)
1035+
container_globalns.update(pandas=pd, pd=pd)
10101036
except ImportError:
1011-
return "general"
1037+
pass
1038+
try:
1039+
import pyarrow as pa
1040+
container_types["arrow"] = (pa.Array, pa.ChunkedArray)
1041+
container_globalns.update(pyarrow=pa, pa=pa)
1042+
except ImportError:
1043+
pass
10121044

1013-
pandas_types = (pd.Series, pd.DataFrame)
1014-
pandas_globalns = {
1015-
"pandas": pd,
1016-
"pd": pd,
1017-
**declaration_context.globalns,
1018-
}
1045+
modes: set[str] = set()
10191046
for name in getattr(hint_func, "__annotations__", {}):
10201047
if name in declaration_context.ignored_hint_names:
10211048
continue
10221049
hint = _resolve_callable_annotation(
10231050
declaration_context,
10241051
name,
1025-
globalns=pandas_globalns,
1052+
globalns={**container_globalns, **declaration_context.globalns},
1053+
)
1054+
modes.update(
1055+
mode for mode, types in container_types.items()
1056+
if hint in types or (
1057+
mode == "arrow" and isinstance(hint, type) and issubclass(hint, types)
1058+
)
1059+
)
1060+
if len(modes) > 1:
1061+
raise ValueError(
1062+
"UDF annotations contain both pandas and Arrow containers; "
1063+
"specify func_type explicitly."
10261064
)
1027-
if hint in pandas_types:
1028-
return "pandas"
1029-
return "general"
1065+
return next(iter(modes), "general")
10301066

10311067

10321068
# ======================== Worker Adapters ========================

flink-python/pyflink/fn_execution/coder_impl_fast.pxd

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,13 +90,14 @@ cdef class RowCoderImpl(FieldCoderImpl):
9090
cdef MaskUtils _mask_utils
9191

9292
cdef class ArrowCoderImpl(FieldCoderImpl):
93+
cdef object _batch_format
9394
cdef object _schema
9495
cdef list _field_types
9596
cdef object _timezone
9697
cdef object _resettable_io
9798
cdef object _batch_reader
9899

99-
cdef list decode_one_batch_from_stream(self, InputStream in_stream, size_t size)
100+
cdef decode_one_batch_from_stream(self, InputStream in_stream, size_t size)
100101

101102
cdef class OverWindowArrowCoderImpl(FieldCoderImpl):
102103
cdef ArrowCoderImpl _arrow_coder

flink-python/pyflink/fn_execution/coder_impl_fast.pyx

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ from pyflink.datastream.window import CountWindow, TimeWindow, GlobalWindow
3636
from pyflink.fn_execution.formats.avro import FlinkAvroDecoder, FlinkAvroDatumReader, \
3737
FlinkAvroBufferWrapper, FlinkAvroEncoder, FlinkAvroDatumWriter
3838
from pyflink.fn_execution.ResettableIO import ResettableIO
39-
from pyflink.table.utils import pandas_to_arrow, arrow_to_pandas
39+
from pyflink.table.utils import pandas_to_arrow, arrow_to_pandas, validate_arrow_batch
4040

4141
ROW_KIND_BIT_SIZE = 2
4242

@@ -431,7 +431,8 @@ cdef class ArrowCoderImpl(FieldCoderImpl):
431431
A coder for arrow format data.
432432
"""
433433

434-
def __init__(self, schema, row_type, timezone):
434+
def __init__(self, schema, row_type, timezone, batch_format="PANDAS"):
435+
self._batch_format = batch_format
435436
self._schema = schema
436437
self._field_types = row_type.field_types()
437438
self._timezone = timezone
@@ -443,16 +444,22 @@ cdef class ArrowCoderImpl(FieldCoderImpl):
443444

444445
self._resettable_io.set_output_stream(out_stream)
445446
batch_writer = pa.RecordBatchStreamWriter(self._resettable_io, self._schema)
446-
batch_writer.write_batch(
447-
pandas_to_arrow(self._schema, self._timezone, self._field_types, cols))
447+
if self._batch_format == "ARROW":
448+
batch = validate_arrow_batch(cols, self._schema, self._field_types)
449+
else:
450+
batch = pandas_to_arrow(self._schema, self._timezone, self._field_types, cols)
451+
batch_writer.write_batch(batch)
448452

449453
cpdef decode_from_stream(self, InputStream in_stream, size_t size):
450454
return self.decode_one_batch_from_stream(in_stream, size)
451455

452-
cdef list decode_one_batch_from_stream(self, InputStream in_stream, size_t size):
456+
cdef decode_one_batch_from_stream(self, InputStream in_stream, size_t size):
453457
self._resettable_io.set_input_bytes(in_stream.read(size))
454458
# there is only one arrow batch in the underlying input stream
455-
return arrow_to_pandas(self._timezone, self._field_types, [next(self._batch_reader)])
459+
batch = next(self._batch_reader)
460+
if self._batch_format == "ARROW":
461+
return batch
462+
return arrow_to_pandas(self._timezone, self._field_types, [batch])
456463

457464
def _load_from_stream(self, stream):
458465
import pyarrow as pa

0 commit comments

Comments
 (0)