This is an automated email from the ASF dual-hosted git repository.
tvalentyn pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/beam.git
The following commit(s) were added to refs/heads/master by this push:
new 3463d1efa9d Add opt-in lazy Dask side inputs with worker-safe
computation (#40496)
3463d1efa9d is described below
commit 3463d1efa9d3375737d3e3a4623a809ca38272b4
Author: GAURAV THAKUR <[email protected]>
AuthorDate: Thu Oct 15 02:22:18 2026 +0530
Add opt-in lazy Dask side inputs with worker-safe computation (#40496)
* [BEAM-40282] Stream Dask side inputs by partition
* [BEAM-40282] Guard lazy Dask side inputs in worker tasks
* Collect Dask runner coverage for Codecov
* Fix Dask coverage import initialization in CI
---
.github/workflows/dask_runner_tests.yml | 15 ++
.../python/apache_beam/runners/dask/dask_runner.py | 16 +-
.../apache_beam/runners/dask/dask_runner_test.py | 173 +++++++++++++++++++++
.../runners/dask/transform_evaluator.py | 42 ++++-
4 files changed, 237 insertions(+), 9 deletions(-)
diff --git a/.github/workflows/dask_runner_tests.yml
b/.github/workflows/dask_runner_tests.yml
index 5125c00d958..7df34c06a2e 100644
--- a/.github/workflows/dask_runner_tests.yml
+++ b/.github/workflows/dask_runner_tests.yml
@@ -91,6 +91,21 @@ jobs:
if: startsWith(matrix.os, 'windows')
working-directory: ./sdks/python
run: tox -c tox.ini -e ${{ matrix.params.tox_env }}-win-dask
+ - name: Collect Dask unit-test coverage
+ if: matrix.os == 'ubuntu-latest'
+ working-directory: ./sdks/python
+ run: |
+ python -m pip install pytest-cov==3.0.0
+ python -m pytest -q apache_beam/runners/dask/dask_runner_test.py \
+ -k 'DaskBagWindowedIteratorTest or DaskOptionsTest' \
+ --cov=apache_beam/runners/dask --cov-report=xml:coverage.xml
+ - name: Upload Dask Python coverage
+ if: matrix.os == 'ubuntu-latest'
+ uses: codecov/codecov-action@v7
+ with:
+ flags: python
+ files: ./sdks/python/coverage.xml
+ disable_search: true
- name: Upload test logs
uses: actions/upload-artifact@v7
if: always()
diff --git a/sdks/python/apache_beam/runners/dask/dask_runner.py
b/sdks/python/apache_beam/runners/dask/dask_runner.py
index b14449ab2fa..0c800196c2f 100644
--- a/sdks/python/apache_beam/runners/dask/dask_runner.py
+++ b/sdks/python/apache_beam/runners/dask/dask_runner.py
@@ -120,6 +120,13 @@ class DaskOptions(PipelineOptions):
default=None,
help='The length of each `dask.Bag` partition. When unspecified, '
'an educated guess is made.')
+ parser.add_argument(
+ '--dask_lazy_side_inputs',
+ dest='lazy_side_inputs',
+ action='store_true',
+ help='Opt in to partition-at-a-time evaluation of iterable side '
+ 'inputs to reduce peak memory. May increase scheduler overhead and '
+ 'recompute side inputs; AsList still materializes its full view.')
@dataclasses.dataclass
@@ -166,7 +173,8 @@ class DaskRunnerResult(PipelineResult):
class DaskRunner(BundleBasedDirectRunner):
"""Executes a pipeline on a Dask distributed client."""
@staticmethod
- def to_dask_bag_visitor(bag_kwargs=None) -> PipelineVisitor:
+ def to_dask_bag_visitor(
+ bag_kwargs=None, lazy_side_inputs: bool = False) -> PipelineVisitor:
from dask import bag as db
if bag_kwargs is None:
@@ -210,7 +218,8 @@ class DaskRunner(BundleBasedDirectRunner):
SideInputMap(
type(si),
si._view_options(),
- DaskBagWindowedIterator(si_asbag, si._window_mapping_fn)))
+ DaskBagWindowedIterator(
+ si_asbag, si._window_mapping_fn, lazy_side_inputs)))
op_kws["side_inputs"] = bag_side_inputs
@@ -238,11 +247,12 @@ class DaskRunner(BundleBasedDirectRunner):
dask_options = options.view_as(DaskOptions).get_all_options(
drop_default=True, current_only=True)
bag_kwargs = DaskOptions._extract_bag_kwargs(dask_options)
+ lazy_side_inputs = dask_options.pop('lazy_side_inputs', False)
client = ddist.Client(**dask_options)
pipeline.replace_all(dask_overrides())
- dask_visitor = self.to_dask_bag_visitor(bag_kwargs)
+ dask_visitor = self.to_dask_bag_visitor(bag_kwargs, lazy_side_inputs)
pipeline.visit(dask_visitor)
# The dictionary in this visitor keeps a mapping of every Beam
# PTransform to the equivalent Bag operation. This is highly
diff --git a/sdks/python/apache_beam/runners/dask/dask_runner_test.py
b/sdks/python/apache_beam/runners/dask/dask_runner_test.py
index 160a8f3cc51..90f328f4e51 100644
--- a/sdks/python/apache_beam/runners/dask/dask_runner_test.py
+++ b/sdks/python/apache_beam/runners/dask/dask_runner_test.py
@@ -16,6 +16,8 @@
#
import datetime
import inspect
+import logging
+import multiprocessing
import typing as t
import unittest
@@ -25,17 +27,188 @@ from apache_beam.testing import test_pipeline
from apache_beam.testing.util import assert_that
from apache_beam.testing.util import equal_to
from apache_beam.transforms import window
+from apache_beam.utils.windowed_value import WindowedValue
try:
import dask
+ import dask.bag as db
import dask.distributed as ddist
from apache_beam.runners.dask.dask_runner import DaskOptions # pylint:
disable=ungrouped-imports
from apache_beam.runners.dask.dask_runner import DaskRunner # pylint:
disable=ungrouped-imports
+ from apache_beam.runners.dask.transform_evaluator import
DaskBagWindowedIterator # pylint: disable=ungrouped-imports
except (ImportError, ModuleNotFoundError):
raise unittest.SkipTest('Dask must be installed to run tests.')
+def _consume_distributed_side_inputs(value, iterable, values, one):
+ ddist.get_worker() # Assert that side inputs are consumed in a worker task.
+ return value, list(iterable), values, one
+
+
+def _run_distributed_side_input_pipeline(lazy, n_workers, threads_per_worker):
+ with ddist.LocalCluster(n_workers=n_workers,
+ threads_per_worker=threads_per_worker,
+ processes=False,
+ dashboard_address=None,
+ silence_logs=logging.ERROR) as cluster:
+ with ddist.Client(cluster):
+ args = [
+ '--dask_client_address',
+ cluster.scheduler_address,
+ '--dask_partition_size',
+ '1'
+ ]
+ if lazy:
+ args.append('--dask_lazy_side_inputs')
+ options = PipelineOptions(args)
+ with test_pipeline.TestPipeline(runner=DaskRunner(),
+ options=options) as p:
+ main = p | 'main' >> beam.Create([10])
+ side = p | 'side' >> beam.Create([2, 3])
+ singleton = p | 'singleton' >> beam.Create([5])
+ result = main | beam.Map(
+ _consume_distributed_side_inputs,
+ beam.pvalue.AsIter(side),
+ beam.pvalue.AsList(side),
+ beam.pvalue.AsSingleton(singleton))
+ assert_that(result, equal_to([(10, [2, 3], [2, 3], 5)]))
+ p.result.client.close()
+
+
+def _run_distributed_partition_error():
+ @dask.delayed
+ def fail():
+ raise ValueError('later partition failed')
+
+ def consume():
+ bag = db.from_delayed([dask.delayed(lambda: [1])(), fail()])
+ values = iter(
+ DaskBagWindowedIterator(
+ bag, window.GlobalWindows(), lazy_side_inputs=True))
+ assert next(values).value == 1
+ try:
+ next(values)
+ except ValueError as exc:
+ assert str(exc) == 'later partition failed'
+ else:
+ raise AssertionError('later partition error was swallowed')
+
+ with ddist.LocalCluster(n_workers=1,
+ threads_per_worker=1,
+ processes=False,
+ dashboard_address=None,
+ silence_logs=logging.ERROR) as cluster:
+ with ddist.Client(cluster) as client:
+ client.submit(consume).result(timeout=20)
+
+
+class DaskBagWindowedIteratorTest(unittest.TestCase):
+ def test_default_materializes_bag_before_first_value(self):
+ computed = []
+
+ @dask.delayed
+ def partition(number):
+ computed.append(number)
+ return [number]
+
+ bag = db.from_delayed([partition(1), partition(2)])
+ with dask.config.set(scheduler='synchronous'):
+ values = iter(DaskBagWindowedIterator(bag, window.GlobalWindows()))
+ self.assertEqual(next(values).value, 1)
+ self.assertCountEqual(computed, [1, 2])
+
+ def test_computes_partitions_as_values_are_consumed(self):
+ computed = []
+
+ @dask.delayed
+ def partition(number):
+ computed.append(number)
+ return [number]
+
+ bag = db.from_delayed([partition(1), partition(2)])
+ with dask.config.set(scheduler='synchronous'):
+ values = iter(
+ DaskBagWindowedIterator(
+ bag, window.GlobalWindows(), lazy_side_inputs=True))
+ self.assertEqual(next(values).value, 1)
+ self.assertEqual(computed, [1])
+ self.assertEqual(next(values).value, 2)
+ self.assertEqual(computed, [1, 2])
+ self.assertRaises(StopIteration, next, values)
+
+ def test_empty_partitions_and_repeated_consumption(self):
+ bag = db.from_delayed([
+ dask.delayed(lambda: [])(),
+ dask.delayed(lambda: [1, 2])(),
+ ])
+ side_input = DaskBagWindowedIterator(
+ bag, window.GlobalWindows(), lazy_side_inputs=True)
+ with dask.config.set(scheduler='synchronous'):
+ self.assertEqual([value.value for value in side_input], [1, 2])
+ self.assertEqual([value.value for value in side_input], [1, 2])
+
+ def test_partition_error_is_raised_when_reached(self):
+ @dask.delayed
+ def failing_partition():
+ raise ValueError('partition failed')
+
+ bag = db.from_delayed([
+ dask.delayed(lambda: [1])(),
+ failing_partition(),
+ ])
+ with dask.config.set(scheduler='synchronous'):
+ values = iter(
+ DaskBagWindowedIterator(
+ bag, window.GlobalWindows(), lazy_side_inputs=True))
+ self.assertEqual(next(values).value, 1)
+ with self.assertRaisesRegex(ValueError, 'partition failed'):
+ next(values)
+
+ def test_preserves_order_and_window_conversion(self):
+ existing = WindowedValue('existing', 1, (window.IntervalWindow(0, 5), ))
+ bag = db.from_sequence(
+ [window.TimestampedValue('timestamped', 7), existing, 'plain'],
+ partition_size=1)
+ with dask.config.set(scheduler='synchronous'):
+ values = list(
+ DaskBagWindowedIterator(
+ bag, window.FixedWindows(5), lazy_side_inputs=True))
+ self.assertEqual([value.value for value in values],
+ ['timestamped', 'existing', 'plain'])
+ self.assertEqual(values[0].windows, (window.IntervalWindow(5, 10), ))
+ self.assertIs(values[1], existing)
+ self.assertEqual(values[2].windows, (window.GlobalWindow(), ))
+
+
+class DaskDistributedSideInputTest(unittest.TestCase):
+ def _run_with_watchdog(self, target, *args):
+ process = multiprocessing.get_context('spawn').Process(
+ target=target, args=args)
+ process.start()
+ process.join(timeout=45)
+ if process.is_alive():
+ process.terminate()
+ process.join(timeout=5)
+ if process.is_alive():
+ process.kill()
+ process.join()
+ self.fail('distributed side-input computation timed out')
+ self.assertEqual(process.exitcode, 0)
+
+ def test_lazy_pipeline_with_one_worker_one_thread(self):
+ self._run_with_watchdog(_run_distributed_side_input_pipeline, True, 1, 1)
+
+ def test_lazy_pipeline_with_two_workers(self):
+ self._run_with_watchdog(_run_distributed_side_input_pipeline, True, 2, 2)
+
+ def test_default_pipeline_with_one_worker_one_thread(self):
+ self._run_with_watchdog(_run_distributed_side_input_pipeline, False, 1, 1)
+
+ def test_later_partition_error_in_worker(self):
+ self._run_with_watchdog(_run_distributed_partition_error)
+
+
class DaskOptionsTest(unittest.TestCase):
def test_parses_connection_timeout__defaults_to_none(self):
default_options = PipelineOptions([])
diff --git a/sdks/python/apache_beam/runners/dask/transform_evaluator.py
b/sdks/python/apache_beam/runners/dask/transform_evaluator.py
index 43ac488b293..a69b98a6d5f 100644
--- a/sdks/python/apache_beam/runners/dask/transform_evaluator.py
+++ b/sdks/python/apache_beam/runners/dask/transform_evaluator.py
@@ -83,17 +83,47 @@ def defenestrate(x):
@dataclasses.dataclass
class DaskBagWindowedIterator:
- """Iterator for `apache_beam.transforms.sideinputs.SideInputMap`"""
+ """Iterator for `apache_beam.transforms.sideinputs.SideInputMap`.
+
+ The default computes the whole bag for compatibility. With lazy side inputs,
+ each partition is computed when reached by iteration. Computations launched
+ inside a Dask worker use worker_client to release the worker's task slot.
+ """
bag: db.Bag
window_fn: WindowFn
+ lazy_side_inputs: bool = False
+
+ @staticmethod
+ def _compute(collection):
+ from dask.distributed import get_worker
+ from dask.distributed import worker_client
+
+ try:
+ get_worker()
+ except ValueError:
+ # Outside a worker, use the configured Dask scheduler.
+ return collection.compute()
+
+ # A blocking nested computation must release the worker's task slot.
+ # Close the context before yielding so partial iteration cannot leave a
+ # worker thread seceded from its pool.
+ with worker_client() as client:
+ return client.compute(collection).result()
def __iter__(self):
- # FIXME(cisaacstern): list() is likely inefficient, since it presumably
- # materializes the full result before iterating over it. doing this for
- # now as a proof-of-concept. can we can generate results incrementally?
- for result in list(self.bag):
- yield get_windowed_value(result, self.window_fn)
+ if self.lazy_side_inputs:
+ # AsIter can consume one partition at a time. AsList still materializes
+ # the complete view, as required by its side-input semantics.
+ for partition in self.bag.to_delayed():
+ for result in self._compute(partition):
+ yield get_windowed_value(result, self.window_fn)
+ else:
+ # FIXME(cisaacstern): The original list(self.bag) materializes the full
+ # side input before iteration. Keep that behavior by default until a
+ # shared or storage-backed side-input design can avoid its memory cost.
+ for result in self._compute(self.bag):
+ yield get_windowed_value(result, self.window_fn)
@dataclasses.dataclass