blob: 1e133f6e219b9c47698d99530b8cc5e99d9d8916 [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.
#
"""
Tests for pipelined Python UDF execution mode.
These tests run with spark.python.udf.pipelined.enabled=true to verify
correctness of the pipelined data transfer path for various UDF types.
"""
import os
import unittest
from pyspark import SparkConf
from pyspark.sql.functions import col, pandas_udf, udf
from pyspark.sql.types import (
DoubleType,
LongType,
StringType,
StructType,
StructField,
)
from pyspark.testing.sqlutils import ReusedSQLTestCase
from pyspark.testing.utils import (
have_pandas,
have_pyarrow,
pandas_requirement_message,
pyarrow_requirement_message,
)
if have_pandas:
import pandas as pd
@unittest.skipIf(
not have_pandas or not have_pyarrow,
pandas_requirement_message or pyarrow_requirement_message,
)
class PipelinedUDFTests(ReusedSQLTestCase):
"""Tests that run with pipelined mode enabled."""
@classmethod
def conf(cls):
return (
SparkConf()
.set("spark.python.udf.pipelined.enabled", "true")
.set("spark.sql.execution.arrow.pyspark.enabled", "true")
)
def test_pipelined_mode_is_active(self):
"""Verify the pipelined code path is actually being used."""
@pandas_udf(StringType())
def check_env(x: pd.Series) -> pd.Series:
# SPARK_PIPELINED_UDF is set by the JVM when pipelined mode is enabled.
flag = os.environ.get("SPARK_PIPELINED_UDF", "not_set")
return pd.Series([flag] * len(x))
result = self.spark.range(1).select(check_env(col("id"))).first()[0]
self.assertEqual(result, "1", "JVM should set SPARK_PIPELINED_UDF=1")
def test_scalar_arrow_udf(self):
"""Basic scalar Arrow UDF with pipelined mode."""
@pandas_udf(LongType())
def add_one(x: pd.Series) -> pd.Series:
return x + 1
result = (
self.spark.range(100).select(col("id"), add_one(col("id")).alias("result")).collect()
)
for row in result:
self.assertEqual(row.result, row.id + 1)
def test_scalar_arrow_udf_string(self):
"""Scalar Arrow UDF with string type."""
@pandas_udf(StringType())
def to_str(x: pd.Series) -> pd.Series:
return x.astype(str) + "_val"
result = self.spark.range(50).select(col("id"), to_str(col("id")).alias("s")).collect()
for row in result:
self.assertEqual(row.s, f"{row.id}_val")
def test_multiple_udf_columns(self):
"""Multiple UDF columns in a single query."""
@pandas_udf(LongType())
def udf_a(x: pd.Series) -> pd.Series:
return x + 1
@pandas_udf(LongType())
def udf_b(x: pd.Series) -> pd.Series:
return x * 2
@pandas_udf(LongType())
def udf_c(x: pd.Series) -> pd.Series:
return x - 1
result = (
self.spark.range(100)
.select(
col("id"),
udf_a(col("id")).alias("a"),
udf_b(col("id")).alias("b"),
udf_c(col("id")).alias("c"),
)
.collect()
)
for row in result:
self.assertEqual(row.a, row.id + 1)
self.assertEqual(row.b, row.id * 2)
self.assertEqual(row.c, row.id - 1)
def test_multiple_partitions(self):
"""UDF across multiple partitions."""
@pandas_udf(LongType())
def add_one(x: pd.Series) -> pd.Series:
return x + 1
result = (
self.spark.range(1000, numPartitions=4)
.select(col("id"), add_one(col("id")).alias("result"))
.collect()
)
self.assertEqual(len(result), 1000)
for row in result:
self.assertEqual(row.result, row.id + 1)
def test_empty_partition(self):
"""UDF with some empty partitions."""
@pandas_udf(LongType())
def add_one(x: pd.Series) -> pd.Series:
return x + 1
# 2 rows across 4 partitions means some partitions are empty
result = (
self.spark.range(2, numPartitions=4)
.select(col("id"), add_one(col("id")).alias("result"))
.collect()
)
self.assertEqual(len(result), 2)
for row in result:
self.assertEqual(row.result, row.id + 1)
def test_chained_udf(self):
"""Chained UDF calls."""
@pandas_udf(LongType())
def add_one(x: pd.Series) -> pd.Series:
return x + 1
@pandas_udf(LongType())
def double_it(x: pd.Series) -> pd.Series:
return x * 2
result = (
self.spark.range(50).select(double_it(add_one(col("id"))).alias("result")).collect()
)
for row in result:
self.assertEqual(row.result, (row.result // 2) * 2) # even number
def test_udf_with_null(self):
"""UDF handling null values."""
@pandas_udf(LongType())
def add_one(x: pd.Series) -> pd.Series:
return x + 1
df = self.spark.createDataFrame(
[(1,), (None,), (3,), (None,), (5,)],
schema=StructType([StructField("v", LongType(), True)]),
)
result = df.select(col("v"), add_one(col("v")).alias("result")).collect()
expected = [(1, 2), (None, None), (3, 4), (None, None), (5, 6)]
for row, (v, r) in zip(result, expected):
self.assertEqual(row.v, v)
self.assertEqual(row.result, r)
def test_grouped_agg_udf(self):
"""Grouped aggregation UDF (UDAF) with pipelined mode."""
@pandas_udf(DoubleType())
def mean_udf(x: pd.Series) -> float:
return float(x.mean())
df = self.spark.range(100).selectExpr("id", "id % 5 as grp")
result = df.groupBy("grp").agg(mean_udf(col("id")).alias("avg")).orderBy("grp").collect()
self.assertEqual(len(result), 5)
# grp=0: mean of 0,5,10,...,95 = 47.5
self.assertAlmostEqual(result[0].avg, 47.5)
def test_scalar_udf_large_data(self):
"""Scalar UDF with large data to exercise backpressure."""
@pandas_udf(LongType())
def add_one(x: pd.Series) -> pd.Series:
return x + 1
result = (
self.spark.range(500000)
.select(add_one(col("id")).alias("result"))
.agg({"result": "sum"})
.collect()
)
# sum of (1..500000) = 500000 * 500001 / 2 = 125000250000
self.assertEqual(result[0][0], 125000250000)
def test_batched_udf(self):
"""Non-Arrow batched UDF (pickle serialization)."""
@udf(StringType())
def simple_udf(x):
return str(x) + "_done"
result = self.spark.range(50).select(col("id"), simple_udf(col("id")).alias("s")).collect()
for row in result:
self.assertEqual(row.s, f"{row.id}_done")
def test_udf_exception_propagation(self):
"""UDF that raises an exception should propagate correctly."""
@pandas_udf(LongType())
def bad_udf(x: pd.Series) -> pd.Series:
raise ValueError("intentional error")
with self.assertRaisesRegex(Exception, "intentional error"):
self.spark.range(10).select(bad_udf(col("id"))).collect()
def test_offheap_reader_with_head_does_not_segfault(self):
"""Regression test: SPARK-33277 reproducer adapted for pipelined mode.
With the off-heap vectorized Parquet reader, head() triggers a
driver-side cancel of the still-running task. The task completion
listener for the writer thread must wait for the writer to actually
exit before subsequent listeners free off-heap memory backing the
rows the writer is still serializing. Otherwise the writer reads
invalidated off-heap memory and the executor segfaults.
The race is probabilistic (the SPARK-33277 PR reported ~3% failure
rate without the fix), so we repeat the head() many times to make
the test reliably crash without the fix while still finishing fast.
"""
import shutil
import tempfile
path = tempfile.mkdtemp()
shutil.rmtree(path)
try:
self.spark.range(0, 200000, 1, 1).write.parquet(path)
@pandas_udf(LongType())
def to_zero(x: pd.Series) -> pd.Series:
return pd.Series([0] * len(x))
with self.sql_conf({"spark.sql.columnVector.offheap.enabled": "true"}):
for _ in range(100):
row = self.spark.read.parquet(path).select(to_zero(col("id"))).head()
self.assertEqual(row[0], 0)
finally:
shutil.rmtree(path, ignore_errors=True)
if __name__ == "__main__":
from pyspark.testing import main
main()