test_dask.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607
  1. from __future__ import absolute_import, division, print_function
  2. import os
  3. import warnings
  4. from random import random
  5. from time import sleep
  6. from uuid import uuid4
  7. import pytest
  8. from .. import Parallel, delayed, parallel_backend, parallel_config
  9. from .._dask import DaskDistributedBackend
  10. from ..parallel import AutoBatchingMixin, ThreadingBackend
  11. from .common import np, with_numpy
  12. from .test_parallel import (
  13. _recursive_backend_info,
  14. _test_deadlock_with_generator,
  15. _test_parallel_unordered_generator_returns_fastest_first, # noqa: E501
  16. )
  17. distributed = pytest.importorskip("distributed")
  18. dask = pytest.importorskip("dask")
  19. # These imports need to be after the pytest.importorskip hence the noqa: E402
  20. from distributed import Client, LocalCluster, get_client # noqa: E402
  21. from distributed.metrics import time # noqa: E402
  22. # Note: pytest requires to manually import all fixtures used in the test
  23. # and their dependencies.
  24. from distributed.utils_test import cleanup, cluster, inc # noqa: E402, F401
  25. @pytest.fixture(scope="function", autouse=True)
  26. def avoid_dask_env_leaks(tmp_path):
  27. # when starting a dask nanny, the environment variable might change.
  28. # this fixture makes sure the environment is reset after the test.
  29. from joblib._parallel_backends import ParallelBackendBase
  30. old_value = {k: os.environ.get(k) for k in ParallelBackendBase.MAX_NUM_THREADS_VARS}
  31. yield
  32. # Reset the environment variables to their original values
  33. for k, v in old_value.items():
  34. if v is None:
  35. os.environ.pop(k, None)
  36. else:
  37. os.environ[k] = v
  38. def noop(*args, **kwargs):
  39. pass
  40. def slow_raise_value_error(condition, duration=0.05):
  41. sleep(duration)
  42. if condition:
  43. raise ValueError("condition evaluated to True")
  44. def count_events(event_name, client):
  45. worker_events = client.run(lambda dask_worker: dask_worker.log)
  46. event_counts = {}
  47. for w, events in worker_events.items():
  48. event_counts[w] = len(
  49. [event for event in list(events) if event[1] == event_name]
  50. )
  51. return event_counts
  52. def test_simple(loop):
  53. with cluster() as (s, [a, b]):
  54. with Client(s["address"], loop=loop) as client: # noqa: F841
  55. with parallel_config(backend="dask"):
  56. seq = Parallel()(delayed(inc)(i) for i in range(10))
  57. assert seq == [inc(i) for i in range(10)]
  58. with pytest.raises(ValueError):
  59. Parallel()(
  60. delayed(slow_raise_value_error)(i == 3) for i in range(10)
  61. )
  62. seq = Parallel()(delayed(inc)(i) for i in range(10))
  63. assert seq == [inc(i) for i in range(10)]
  64. def test_dask_backend_uses_autobatching(loop):
  65. assert (
  66. DaskDistributedBackend.compute_batch_size
  67. is AutoBatchingMixin.compute_batch_size
  68. )
  69. with cluster() as (s, [a, b]):
  70. with Client(s["address"], loop=loop) as client: # noqa: F841
  71. with parallel_config(backend="dask"):
  72. with Parallel() as parallel:
  73. # The backend should be initialized with a default
  74. # batch size of 1:
  75. backend = parallel._backend
  76. assert isinstance(backend, DaskDistributedBackend)
  77. assert backend.parallel is parallel
  78. assert backend._effective_batch_size == 1
  79. # Launch many short tasks that should trigger
  80. # auto-batching:
  81. parallel(delayed(lambda: None)() for _ in range(int(1e4)))
  82. assert backend._effective_batch_size > 10
  83. @pytest.mark.parametrize("n_jobs", [2, -1])
  84. @pytest.mark.parametrize("context", [parallel_config, parallel_backend])
  85. def test_parallel_unordered_generator_returns_fastest_first_with_dask(n_jobs, context):
  86. with distributed.Client(n_workers=2, threads_per_worker=2), context("dask"):
  87. _test_parallel_unordered_generator_returns_fastest_first(None, n_jobs)
  88. @with_numpy
  89. @pytest.mark.parametrize("n_jobs", [2, -1])
  90. @pytest.mark.parametrize("return_as", ["generator", "generator_unordered"])
  91. @pytest.mark.parametrize("context", [parallel_config, parallel_backend])
  92. def test_deadlock_with_generator_and_dask(context, return_as, n_jobs):
  93. with distributed.Client(n_workers=2, threads_per_worker=2), context("dask"):
  94. _test_deadlock_with_generator(None, return_as, n_jobs)
  95. @with_numpy
  96. @pytest.mark.parametrize("context", [parallel_config, parallel_backend])
  97. def test_nested_parallelism_with_dask(context):
  98. with distributed.Client(n_workers=2, threads_per_worker=2):
  99. # 10 MB of data as argument to trigger implicit scattering
  100. data = np.ones(int(1e7), dtype=np.uint8)
  101. for i in range(2):
  102. with context("dask"):
  103. backend_types_and_levels = _recursive_backend_info(data=data)
  104. assert len(backend_types_and_levels) == 4
  105. assert all(
  106. name == "DaskDistributedBackend" for name, _ in backend_types_and_levels
  107. )
  108. # No argument
  109. with context("dask"):
  110. backend_types_and_levels = _recursive_backend_info()
  111. assert len(backend_types_and_levels) == 4
  112. assert all(
  113. name == "DaskDistributedBackend" for name, _ in backend_types_and_levels
  114. )
  115. def random2():
  116. return random()
  117. def test_dont_assume_function_purity(loop):
  118. with cluster() as (s, [a, b]):
  119. with Client(s["address"], loop=loop) as client: # noqa: F841
  120. with parallel_config(backend="dask"):
  121. x, y = Parallel()(delayed(random2)() for i in range(2))
  122. assert x != y
  123. @pytest.mark.parametrize("mixed", [True, False])
  124. def test_dask_funcname(loop, mixed):
  125. from joblib._dask import Batch
  126. if not mixed:
  127. tasks = [delayed(inc)(i) for i in range(4)]
  128. batch_repr = "batch_of_inc_4_calls"
  129. else:
  130. tasks = [delayed(abs)(i) if i % 2 else delayed(inc)(i) for i in range(4)]
  131. batch_repr = "mixed_batch_of_inc_4_calls"
  132. assert repr(Batch(tasks)) == batch_repr
  133. with cluster() as (s, [a, b]):
  134. with Client(s["address"], loop=loop) as client:
  135. with parallel_config(backend="dask"):
  136. _ = Parallel(batch_size=2, pre_dispatch="all")(tasks)
  137. def f(dask_scheduler):
  138. return list(dask_scheduler.transition_log)
  139. batch_repr = batch_repr.replace("4", "2")
  140. log = client.run_on_scheduler(f)
  141. assert all("batch_of_inc" in tup[0] for tup in log)
  142. def test_no_undesired_distributed_cache_hit():
  143. # Dask has a pickle cache for callables that are called many times. Because
  144. # the dask backends used to wrap both the functions and the arguments
  145. # under instances of the Batch callable class this caching mechanism could
  146. # lead to bugs as described in: https://github.com/joblib/joblib/pull/1055
  147. # The joblib-dask backend has been refactored to avoid bundling the
  148. # arguments as an attribute of the Batch instance to avoid this problem.
  149. # This test serves as non-regression problem.
  150. # Use a large number of input arguments to give the AutoBatchingMixin
  151. # enough tasks to kick-in.
  152. lists = [[] for _ in range(100)]
  153. np = pytest.importorskip("numpy")
  154. X = np.arange(int(1e6))
  155. def isolated_operation(list_, data=None):
  156. if data is not None:
  157. np.testing.assert_array_equal(data, X)
  158. list_.append(uuid4().hex)
  159. return list_
  160. cluster = LocalCluster(n_workers=1, threads_per_worker=2)
  161. client = Client(cluster)
  162. try:
  163. with parallel_config(backend="dask"):
  164. # dispatches joblib.parallel.BatchedCalls
  165. res = Parallel()(delayed(isolated_operation)(list_) for list_ in lists)
  166. # The original arguments should not have been mutated as the mutation
  167. # happens in the dask worker process.
  168. assert lists == [[] for _ in range(100)]
  169. # Here we did not pass any large numpy array as argument to
  170. # isolated_operation so no scattering event should happen under the
  171. # hood.
  172. counts = count_events("receive-from-scatter", client)
  173. assert sum(counts.values()) == 0
  174. assert all([len(r) == 1 for r in res])
  175. with parallel_config(backend="dask"):
  176. # Append a large array which will be scattered by dask, and
  177. # dispatch joblib._dask.Batch
  178. res = Parallel()(
  179. delayed(isolated_operation)(list_, data=X) for list_ in lists
  180. )
  181. # This time, auto-scattering should have kicked it.
  182. counts = count_events("receive-from-scatter", client)
  183. assert sum(counts.values()) > 0
  184. assert all([len(r) == 1 for r in res])
  185. finally:
  186. client.close(timeout=30)
  187. cluster.close(timeout=30)
  188. class CountSerialized(object):
  189. def __init__(self, x):
  190. self.x = x
  191. self.count = 0
  192. def __add__(self, other):
  193. return self.x + getattr(other, "x", other)
  194. __radd__ = __add__
  195. def __reduce__(self):
  196. self.count += 1
  197. return (CountSerialized, (self.x,))
  198. def add5(a, b, c, d=0, e=0):
  199. return a + b + c + d + e
  200. def test_manual_scatter(loop):
  201. # Let's check that the number of times scattered and non-scattered
  202. # variables are serialized is consistent between `joblib.Parallel` calls
  203. # and equivalent native `client.submit` call.
  204. # Number of serializations can vary from dask to another, so this test only
  205. # checks that `joblib.Parallel` does not add more serialization steps than
  206. # a native `client.submit` call, but does not check for an exact number of
  207. # serialization steps.
  208. w, x, y, z = (CountSerialized(i) for i in range(4))
  209. f = delayed(add5)
  210. tasks = [f(x, y, z, d=4, e=5) for _ in range(10)]
  211. tasks += [
  212. f(x, z, y, d=5, e=4),
  213. f(y, x, z, d=x, e=5),
  214. f(z, z, x, d=z, e=y),
  215. ]
  216. expected = [func(*args, **kwargs) for func, args, kwargs in tasks]
  217. with cluster() as (s, _):
  218. with Client(s["address"], loop=loop) as client: # noqa: F841
  219. with parallel_config(backend="dask", scatter=[w, x, y]):
  220. results_parallel = Parallel(batch_size=1)(tasks)
  221. assert results_parallel == expected
  222. # Check that an error is raised for bad arguments, as scatter must
  223. # take a list/tuple
  224. with pytest.raises(TypeError):
  225. with parallel_config(backend="dask", loop=loop, scatter=1):
  226. pass
  227. # Scattered variables only serialized during scatter. Checking with an
  228. # extra variable as this count can vary from one dask version
  229. # to another.
  230. n_serialization_scatter_with_parallel = w.count
  231. assert x.count == n_serialization_scatter_with_parallel
  232. assert y.count == n_serialization_scatter_with_parallel
  233. n_serialization_with_parallel = z.count
  234. # Reset the cluster and the serialization count
  235. for var in (w, x, y, z):
  236. var.count = 0
  237. with cluster() as (s, _):
  238. with Client(s["address"], loop=loop) as client: # noqa: F841
  239. scattered = dict()
  240. for obj in w, x, y:
  241. scattered[id(obj)] = client.scatter(obj, broadcast=True)
  242. results_native = [
  243. client.submit(
  244. func,
  245. *(scattered.get(id(arg), arg) for arg in args),
  246. **dict(
  247. (key, scattered.get(id(value), value))
  248. for (key, value) in kwargs.items()
  249. ),
  250. key=str(uuid4()),
  251. ).result()
  252. for (func, args, kwargs) in tasks
  253. ]
  254. assert results_native == expected
  255. # Now check that the number of serialization steps is the same for joblib
  256. # and native dask calls.
  257. n_serialization_scatter_native = w.count
  258. assert x.count == n_serialization_scatter_native
  259. assert y.count == n_serialization_scatter_native
  260. assert n_serialization_scatter_with_parallel == n_serialization_scatter_native
  261. distributed_version = tuple(int(v) for v in distributed.__version__.split("."))
  262. if distributed_version < (2023, 4):
  263. # Previous to 2023.4, the serialization was adding an extra call to
  264. # __reduce__ for the last job `f(z, z, x, d=z, e=y)`, because `z`
  265. # appears both in the args and kwargs, which is not the case when
  266. # running with joblib. Cope with this discrepancy.
  267. assert z.count == n_serialization_with_parallel + 1
  268. else:
  269. assert z.count == n_serialization_with_parallel
  270. # When the same IOLoop is used for multiple clients in a row, use
  271. # loop_in_thread instead of loop to prevent the Client from closing it. See
  272. # dask/distributed #4112
  273. def test_auto_scatter(loop_in_thread):
  274. np = pytest.importorskip("numpy")
  275. data1 = np.ones(int(1e4), dtype=np.uint8)
  276. data2 = np.ones(int(1e4), dtype=np.uint8)
  277. data_to_process = ([data1] * 3) + ([data2] * 3)
  278. with cluster() as (s, [a, b]):
  279. with Client(s["address"], loop=loop_in_thread) as client:
  280. with parallel_config(backend="dask"):
  281. # Passing the same data as arg and kwarg triggers a single
  282. # scatter operation whose result is reused.
  283. Parallel()(
  284. delayed(noop)(data, data, i, opt=data)
  285. for i, data in enumerate(data_to_process)
  286. )
  287. # By default large array are automatically scattered with
  288. # broadcast=1 which means that one worker must directly receive
  289. # the data from the scatter operation once.
  290. counts = count_events("receive-from-scatter", client)
  291. assert counts[a["address"]] + counts[b["address"]] == 2
  292. with cluster() as (s, [a, b]):
  293. with Client(s["address"], loop=loop_in_thread) as client:
  294. with parallel_config(backend="dask"):
  295. Parallel()(delayed(noop)(data1[:3], i) for i in range(5))
  296. # Small arrays are passed within the task definition without going
  297. # through a scatter operation.
  298. counts = count_events("receive-from-scatter", client)
  299. assert counts[a["address"]] == 0
  300. assert counts[b["address"]] == 0
  301. @pytest.mark.parametrize("retry_no", list(range(2)))
  302. def test_nested_scatter(loop, retry_no):
  303. np = pytest.importorskip("numpy")
  304. NUM_INNER_TASKS = 10
  305. NUM_OUTER_TASKS = 10
  306. def my_sum(x, i, j):
  307. return np.sum(x)
  308. def outer_function_joblib(array, i):
  309. client = get_client() # noqa
  310. with parallel_config(backend="dask"):
  311. results = Parallel()(
  312. delayed(my_sum)(array[j:], i, j) for j in range(NUM_INNER_TASKS)
  313. )
  314. return sum(results)
  315. with cluster() as (s, [a, b]):
  316. with Client(s["address"], loop=loop) as _:
  317. with parallel_config(backend="dask"):
  318. my_array = np.ones(10000)
  319. _ = Parallel()(
  320. delayed(outer_function_joblib)(my_array[i:], i)
  321. for i in range(NUM_OUTER_TASKS)
  322. )
  323. def test_nested_backend_context_manager(loop_in_thread):
  324. def get_nested_pids():
  325. pids = set(Parallel(n_jobs=2)(delayed(os.getpid)() for _ in range(2)))
  326. pids |= set(Parallel(n_jobs=2)(delayed(os.getpid)() for _ in range(2)))
  327. return pids
  328. with cluster() as (s, [a, b]):
  329. with Client(s["address"], loop=loop_in_thread) as client:
  330. with parallel_config(backend="dask"):
  331. pid_groups = Parallel(n_jobs=2)(
  332. delayed(get_nested_pids)() for _ in range(10)
  333. )
  334. for pid_group in pid_groups:
  335. assert len(set(pid_group)) <= 2
  336. # No deadlocks
  337. with Client(s["address"], loop=loop_in_thread) as client: # noqa: F841
  338. with parallel_config(backend="dask"):
  339. pid_groups = Parallel(n_jobs=2)(
  340. delayed(get_nested_pids)() for _ in range(10)
  341. )
  342. for pid_group in pid_groups:
  343. assert len(set(pid_group)) <= 2
  344. def test_nested_backend_context_manager_implicit_n_jobs(loop):
  345. # Check that Parallel with no explicit n_jobs value automatically selects
  346. # all the dask workers, including in nested calls.
  347. def _backend_type(p):
  348. return p._backend.__class__.__name__
  349. def get_nested_implicit_n_jobs():
  350. with Parallel() as p:
  351. return _backend_type(p), p.n_jobs
  352. with cluster() as (s, [a, b]):
  353. with Client(s["address"], loop=loop) as client: # noqa: F841
  354. with parallel_config(backend="dask"):
  355. with Parallel() as p:
  356. assert _backend_type(p) == "DaskDistributedBackend"
  357. assert p.n_jobs == -1
  358. all_nested_n_jobs = p(
  359. delayed(get_nested_implicit_n_jobs)() for _ in range(2)
  360. )
  361. for backend_type, nested_n_jobs in all_nested_n_jobs:
  362. assert backend_type == "DaskDistributedBackend"
  363. assert nested_n_jobs == -1
  364. def test_errors(loop):
  365. with pytest.raises(ValueError) as info:
  366. with parallel_config(backend="dask"):
  367. pass
  368. assert "create a dask client" in str(info.value).lower()
  369. def test_correct_nested_backend(loop):
  370. with cluster() as (s, [a, b]):
  371. with Client(s["address"], loop=loop) as client: # noqa: F841
  372. # No requirement, should be us
  373. with parallel_config(backend="dask"):
  374. result = Parallel(n_jobs=2)(
  375. delayed(outer)(nested_require=None) for _ in range(1)
  376. )
  377. assert isinstance(result[0][0][0], DaskDistributedBackend)
  378. # Require threads, should be threading
  379. with parallel_config(backend="dask"):
  380. result = Parallel(n_jobs=2)(
  381. delayed(outer)(nested_require="sharedmem") for _ in range(1)
  382. )
  383. assert isinstance(result[0][0][0], ThreadingBackend)
  384. def outer(nested_require):
  385. return Parallel(n_jobs=2, prefer="threads")(
  386. delayed(middle)(nested_require) for _ in range(1)
  387. )
  388. def middle(require):
  389. return Parallel(n_jobs=2, require=require)(delayed(inner)() for _ in range(1))
  390. def inner():
  391. return Parallel()._backend
  392. def test_secede_with_no_processes(loop):
  393. # https://github.com/dask/distributed/issues/1775
  394. with Client(loop=loop, processes=False, set_as_default=True):
  395. with parallel_config(backend="dask"):
  396. Parallel(n_jobs=4)(delayed(id)(i) for i in range(2))
  397. def _worker_address(_):
  398. from distributed import get_worker
  399. return get_worker().address
  400. def test_dask_backend_keywords(loop):
  401. with cluster() as (s, [a, b]):
  402. with Client(s["address"], loop=loop) as client: # noqa: F841
  403. with parallel_config(backend="dask", workers=a["address"]):
  404. seq = Parallel()(delayed(_worker_address)(i) for i in range(10))
  405. assert seq == [a["address"]] * 10
  406. with parallel_config(backend="dask", workers=b["address"]):
  407. seq = Parallel()(delayed(_worker_address)(i) for i in range(10))
  408. assert seq == [b["address"]] * 10
  409. def test_scheduler_tasks_cleanup(loop):
  410. with Client(processes=False, loop=loop) as client:
  411. with parallel_config(backend="dask"):
  412. Parallel()(delayed(inc)(i) for i in range(10))
  413. start = time()
  414. while client.cluster.scheduler.tasks:
  415. sleep(0.01)
  416. assert time() < start + 5
  417. assert not client.futures
  418. @pytest.mark.parametrize("cluster_strategy", ["adaptive", "late_scaling"])
  419. @pytest.mark.skipif(
  420. distributed.__version__ <= "2.1.1" and distributed.__version__ >= "1.28.0",
  421. reason="distributed bug - https://github.com/dask/distributed/pull/2841",
  422. )
  423. def test_wait_for_workers(cluster_strategy):
  424. cluster = LocalCluster(n_workers=0, processes=False, threads_per_worker=2)
  425. client = Client(cluster)
  426. if cluster_strategy == "adaptive":
  427. cluster.adapt(minimum=0, maximum=2)
  428. elif cluster_strategy == "late_scaling":
  429. # Tell the cluster to start workers but this is a non-blocking call
  430. # and new workers might take time to connect. In this case the Parallel
  431. # call should wait for at least one worker to come up before starting
  432. # to schedule work.
  433. cluster.scale(2)
  434. try:
  435. with parallel_config(backend="dask"):
  436. # The following should wait a bit for at least one worker to
  437. # become available.
  438. Parallel()(delayed(inc)(i) for i in range(10))
  439. finally:
  440. client.close()
  441. cluster.close()
  442. def test_wait_for_workers_timeout():
  443. # Start a cluster with 0 worker:
  444. cluster = LocalCluster(n_workers=0, processes=False, threads_per_worker=2)
  445. client = Client(cluster)
  446. try:
  447. with parallel_config(backend="dask", wait_for_workers_timeout=0.1):
  448. # Short timeout: DaskDistributedBackend
  449. msg = "DaskDistributedBackend has no worker after 0.1 seconds."
  450. with pytest.raises(TimeoutError, match=msg):
  451. Parallel()(delayed(inc)(i) for i in range(10))
  452. with parallel_config(backend="dask", wait_for_workers_timeout=0):
  453. # No timeout: fallback to generic joblib failure:
  454. msg = "DaskDistributedBackend has no active worker"
  455. with pytest.raises(RuntimeError, match=msg):
  456. Parallel()(delayed(inc)(i) for i in range(10))
  457. finally:
  458. client.close()
  459. cluster.close()
  460. @pytest.mark.parametrize("backend", ["loky", "multiprocessing"])
  461. def test_joblib_warning_inside_dask_daemonic_worker(backend):
  462. cluster = LocalCluster(n_workers=2)
  463. client = Client(cluster)
  464. try:
  465. def func_using_joblib_parallel():
  466. # Somehow trying to check the warning type here (e.g. with
  467. # pytest.warns(UserWarning)) make the test hang. Work-around:
  468. # return the warning record to the client and the warning check is
  469. # done client-side.
  470. with warnings.catch_warnings(record=True) as record:
  471. Parallel(n_jobs=2, backend=backend)(delayed(inc)(i) for i in range(10))
  472. return record
  473. fut = client.submit(func_using_joblib_parallel)
  474. record = fut.result()
  475. assert len(record) == 1
  476. warning = record[0].message
  477. assert isinstance(warning, UserWarning)
  478. assert "distributed.worker.daemon" in str(warning)
  479. finally:
  480. client.close(timeout=30)
  481. cluster.close(timeout=30)