dongjoon-hyun commented on code in PR #59845:
URL: https://github.com/apache/spark/pull/59845#discussion_r4464058668


##########
python/pyspark/eval_handlers/_arrow.py:
##########
@@ -879,13 +879,21 @@ def run(self, split_index: int, data: 
Iterator[pa.RecordBatch]) -> Iterator[pa.R
             phase_timers = None
 
         @fail_on_stopiteration
-        def _evaluate_batch_udf(udf_func: Any, rows: list[tuple]) -> list[Any]:
+        def _evaluate_batch_udf(udf_func: Any, args: list[list[Any]], 
num_rows: int) -> list[Any]:
             if runner_conf.arrow_concurrency_level <= 0:
-                return [udf_func(*row) for row in rows]
+                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:
-                return list(pool.map(lambda row: udf_func(*row), rows))
+                if not args:
+                    return list(pool.map(lambda _: udf_func(), 
range(num_rows)))
+                return list(pool.map(udf_func, *args))

Review Comment:
   `Executor.map()` zips the iterables internally (`fs = [self.submit(fn, 
*args) for args in zip(*iterables)]`) and creates a `Future` per row, so this 
change does not reduce per-row allocations on the thread-pool path. The 
sentence "The `arrow.concurrency.level` thread-pool path changes the same way" 
in the description may be misleading.
   
   Separately (pre-existing, not for this PR): a new `ThreadPoolExecutor` is 
created and shut down per UDF per batch. Reusing a single executor per task 
could be a follow-up.



##########
python/pyspark/eval_handlers/_arrow.py:
##########
@@ -879,13 +879,21 @@ def run(self, split_index: int, data: 
Iterator[pa.RecordBatch]) -> Iterator[pa.R
             phase_timers = None
 
         @fail_on_stopiteration
-        def _evaluate_batch_udf(udf_func: Any, rows: list[tuple]) -> list[Any]:
+        def _evaluate_batch_udf(udf_func: Any, args: list[list[Any]], 
num_rows: int) -> list[Any]:
             if runner_conf.arrow_concurrency_level <= 0:
-                return [udf_func(*row) for row in rows]
+                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)]

Review Comment:
   The PR title and description still describe the first commit: `return 
list(map(udf_func, *args))` and "no tuple is built per row". With this 
follow-up, multi-arg UDFs still create one tuple per row via `zip(*args)` (only 
the materialized list of tuples is avoided), and keyword-argument UDFs also 
build a tuple and a dict per call inside `wrap_kwargs_support`.
   
   In a local micro-benchmark (3-arg UDF, 10k rows x 50 iterations, min of 7 
runs, CPython 3.12), I got:
   
   | Version | Time |
   |---|---|
   | Before this PR | 0.0647s |
   | This PR (d13ff764dcc) | 0.0572s |
   | `list(map(...))` (cdca21c75a5) | 0.0494s |
   
   So the `concat3_udf` numbers in the description seem to be measured with the 
`map()` version and may overstate the gain of the final code. Could you update 
the title/description and re-run `ArrowBatchedUDFTimeBench` with the latest 
commit?



##########
python/pyspark/eval_handlers/_arrow.py:
##########
@@ -879,13 +879,21 @@ def run(self, split_index: int, data: 
Iterator[pa.RecordBatch]) -> Iterator[pa.R
             phase_timers = None
 
         @fail_on_stopiteration
-        def _evaluate_batch_udf(udf_func: Any, rows: list[tuple]) -> list[Any]:
+        def _evaluate_batch_udf(udf_func: Any, args: list[list[Any]], 
num_rows: int) -> list[Any]:

Review Comment:
   Not a blocker: `ArrowBatchedUDFLegacyPandasHandler` still materializes 
`list(zip(...))` per UDF per batch (`_pandas.py` L799-806), and 
`_evaluate_elementwise_udf` in `_elementwise.py` (L243-249) is almost the same 
helper as this one. If reducing per-row allocations is the goal, it may be 
worth applying the same change to the legacy path, or extracting a shared 
helper (with the `StopIteration` note and the zero-arg branch) so that the 
three handlers do not drift apart. A follow-up is fine.



##########
python/pyspark/eval_handlers/tests/test_arrow_eval_type_handlers.py:
##########
@@ -367,6 +367,19 @@ def test_concurrency_level(self):
         out = list(handler.run(0, iter([self._batch(a=list(range(10)), b=[0] * 
10)])))
         self.assertEqual(out[0].column(0).to_pylist(), [v * 2 for v in 
range(10)])
 
+    def test_concurrency_level_multi_and_zero_arg(self):

Review Comment:
   nit: Since this test covers the new thread-pool branches, could you also add 
a keyword-argument UDF here (like `test_passes_arg_by_keyword_offset`)? Then 
every argument shape that goes through `pool.map(udf_func, *args)` is exercised.



##########
python/benchmarks/bench_eval_type.py:
##########
@@ -540,6 +540,7 @@ def _build_scenario(cls, name):
         "identity_udf": (lambda x: x, None, [0]),
         "stringify_udf": (lambda x: str(x), StringType(), [0]),
         "nullcheck_udf": (lambda x: x is not None, BooleanType(), [0]),
+        "concat3_udf": (lambda a, b, c: f"{a}{b}{c}", StringType(), [0, 1, 2]),

Review Comment:
   nit: `_udfs` is shared via `_ArrowBatchedBenchMixin`, so this also adds 8 
scenarios to `ArrowBatchedUDFPeakmemBench` (24 -> 32 cases for each of the Time 
and Peakmem benchmarks). If multi-arg peak memory is not a target, you may want 
to add it only to `ArrowBatchedUDFTimeBench`. Also, the comment above 
(`arg_offsets=[0] means ...`) no longer applies to all entries.



##########
python/pyspark/eval_handlers/tests/test_arrow_eval_type_handlers.py:
##########
@@ -367,6 +367,19 @@ def test_concurrency_level(self):
         out = list(handler.run(0, iter([self._batch(a=list(range(10)), b=[0] * 
10)])))
         self.assertEqual(out[0].column(0).to_pylist(), [v * 2 for v in 
range(10)])
 
+    def test_concurrency_level_multi_and_zero_arg(self):
+        conf = 
RunnerConf({"spark.sql.execution.pythonUDF.arrow.concurrency.level": "4"})
+        handler = self._handler((lambda a, b: a - b, [0, 1], {}, LongType()), 
runner_conf=conf)
+        out = list(handler.run(0, iter([self._batch(a=list(range(10)), b=[1] * 
10)])))
+        self.assertEqual(out[0].column(0).to_pylist(), [v - 1 for v in 
range(10)])
+
+        handler = self._handler(
+            (lambda: 7, [], {}, LongType()), runner_conf=conf, 
input_type=StructType()
+        )
+        empty = pa.RecordBatch.from_pydict({"a": [1, 2, 3]}).select([])
+        out = list(handler.run(0, iter([empty])))
+        self.assertEqual(out[0].column(0).to_pylist(), [7, 7, 7])
+
     def test_stop_iteration_raises_runtime_error(self):

Review Comment:
   This test still checks only the exception type. `RESULT_ROWS_MISMATCH` is 
also a `PySparkRuntimeError`, so the `list(map(...))` regression fixed by 
d13ff764dcc is not caught here. I confirmed that all tests in 
`ArrowBatchedUDFArrowHandlerTests` pass on the first commit (cdca21c75a5) as 
well.
   
   Could you assert the error condition, as 
`test_elementwise_eval_type_handlers.py` does, and add a multi-arg UDF so that 
both the `args[0]` and `zip(*args)` branches are covered?
   
   ```python
   with self.assertRaises(PySparkRuntimeError) as ctx:
       list(handler.run(0, iter([self._batch(a=[1], b=[0])])))
   self.assertEqual(ctx.exception.getCondition(), "STOP_ITERATION_OCCURRED")
   ```



##########
python/pyspark/eval_handlers/_arrow.py:
##########
@@ -920,15 +928,11 @@ def _evaluate_batch_udf(udf_func: Any, rows: list[tuple]) 
-> list[Any]:
                         # Reuse the batch start for the first UDF, avoiding 
another clock read.
                         # Later UDFs start input after the loop transition.
                         input_start_ns = clock()
-                    rows = (
-                        [() for _ in range(num_rows)]
-                        if zero_arg
-                        else list(zip(*[columns[o] for o in offsets]))
-                    )
+                    args = [] if zero_arg else [columns[o] for o in offsets]

Review Comment:
   nit (optional): Since `_evaluate_batch_udf` now checks `if not args`, the 
`zero_arg` flag looks redundant. If `args_kwargs_offsets` were kept empty 
instead of `args_kwargs_offsets or (0,)` (L833), this line could be `args = 
[columns[o] for o in offsets]`, and the `zero_arg` element of `_udf_infos` 
(L829, L834) and the `_NoValue` placeholder column (L922) could go away.



##########
python/pyspark/eval_handlers/_arrow.py:
##########
@@ -920,15 +928,11 @@ def _evaluate_batch_udf(udf_func: Any, rows: list[tuple]) 
-> list[Any]:
                         # Reuse the batch start for the first UDF, avoiding 
another clock read.
                         # Later UDFs start input after the loop transition.
                         input_start_ns = clock()
-                    rows = (
-                        [() for _ in range(num_rows)]
-                        if zero_arg
-                        else list(zip(*[columns[o] for o in offsets]))
-                    )
+                    args = [] if zero_arg else [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, rows)
+                    results = _evaluate_batch_udf(udf_func, args, num_rows)

Review Comment:
   nit: For multi-arg UDFs, the per-row tuple creation (`zip`) moved from 
before `udf_start_ns` (input preparation) into `_evaluate_batch_udf` (UDF 
execution). When detailed timing is enabled, part of the time moves from 
`pythonInputPreparationTime` to `pythonUDFExecutionTime`. It is minor, but it 
might be worth mentioning in the description since the per-phase metrics change 
slightly.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to