Files
2026-07-13 13:17:40 +08:00

398 lines
12 KiB
Python

"""
The following is adapted from Dask release 2021.03.1:
https://github.com/dask/dask/blob/2021.03.1/dask/local.py
"""
import os
import warnings
from queue import Empty, Queue
from typing import Any, Callable, Dict, List, Optional, Tuple, Union
import dask
from dask import config
try:
from dask._task_spec import DataNode, DependenciesMapping
except ImportError:
warnings.warn(
"Dask on Ray is available only on dask>=2024.11.0, "
f"you are on version {dask.__version__}."
)
from dask.callbacks import local_callbacks, unpack_callbacks
from dask.core import flatten, get_dependencies, reverse_dict
from dask.order import order
if os.name == "nt":
# Python 3 windows Queue.get doesn't handle interrupts properly. To
# workaround this we poll at a sufficiently large interval that it
# shouldn't affect performance, but small enough that users trying to kill
# an application shouldn't care.
def queue_get(q):
while True:
try:
return q.get(block=True, timeout=0.1)
except Empty:
pass
else:
def queue_get(q):
return q.get()
def start_state_from_dask(
dsk: Dict[Any, Any],
cache: Optional[Dict[Any, Any]] = None,
sortkey: Optional[Callable[[Any], Any]] = None,
) -> Dict[str, Any]:
"""Start state from a dask.
Args:
dsk: A dask dictionary specifying a workflow.
cache: Temporary storage of results.
sortkey: Function to sort keys.
Returns:
Initial scheduler state dict with keys ``dependencies``, ``dependents``,
``waiting``, ``waiting_data``, ``cache``, ``ready``, ``running``,
``finished``, and ``released``.
Examples:
>>> dsk = {
... 'x': 1,
... 'y': 2,
... 'z': (inc, 'x'),
... 'w': (add, 'z', 'y')} # doctest: +SKIP
>>> from pprint import pprint # doctest: +SKIP
>>> pprint(start_state_from_dask(dsk)) # doctest: +SKIP
{'cache': {'x': 1, 'y': 2},
'dependencies': {'w': {'z', 'y'}, 'x': set(), 'y': set(), 'z': {'x'}},
'dependents': {'w': set(), 'x': {'z'}, 'y': {'w'}, 'z': {'w'}},
'finished': set(),
'ready': ['z'],
'released': set(),
'running': set(),
'waiting': {'w': {'z'}},
'waiting_data': {'x': {'z'}, 'y': {'w'}, 'z': {'w'}}}
"""
if sortkey is None:
sortkey = order(dsk).get
if cache is None:
cache = config.get("cache", None)
if cache is None:
cache = dict()
data_keys = set()
for k, v in dsk.items():
if isinstance(v, DataNode):
cache[k] = v()
data_keys.add(k)
dsk2 = dsk.copy()
dsk2.update(cache)
dependencies = DependenciesMapping(dsk)
waiting = {k: set(v) for k, v in dependencies.items() if k not in data_keys}
dependents = reverse_dict(dependencies)
for a in cache:
for b in dependents.get(a, ()):
waiting[b].remove(a)
waiting_data = {k: v.copy() for k, v in dependents.items() if v}
ready_set = {k for k, v in waiting.items() if not v}
ready = sorted(ready_set, key=sortkey, reverse=True)
waiting = {k: v for k, v in waiting.items() if v}
state = {
"dependencies": dependencies,
"dependents": dependents,
"waiting": waiting,
"waiting_data": waiting_data,
"cache": cache,
"ready": ready,
"running": set(),
"finished": set(),
"released": set(),
}
return state
def execute_task(key, task_info, dumps, loads, get_id, pack_exception):
"""Compute task and handle all administration.
See Also:
_execute_task : actually execute task
"""
try:
task, data = loads(task_info)
result = task(data)
id = get_id()
result = dumps((result, id))
failed = False
except BaseException as e:
result = pack_exception(e, dumps)
failed = True
return key, result, failed
def release_data(key, state, delete=True):
"""Remove data from temporary storage.
See Also:
finish_task
"""
if key in state["waiting_data"]:
assert not state["waiting_data"][key]
del state["waiting_data"][key]
state["released"].add(key)
if delete:
del state["cache"][key]
DEBUG = False
def finish_task(
dsk, key, state, results, sortkey, delete=True, release_data=release_data
):
"""
Update execution state after a task finishes
Mutates. This should run atomically (with a lock).
"""
for dep in sorted(state["dependents"][key], key=sortkey, reverse=True):
s = state["waiting"][dep]
s.remove(key)
if not s:
del state["waiting"][dep]
state["ready"].append(dep)
for dep in state["dependencies"][key]:
if dep in state["waiting_data"]:
s = state["waiting_data"][dep]
s.remove(key)
if not s and dep not in results:
if DEBUG:
from chest.core import nbytes
print(
"Key: %s\tDep: %s\t NBytes: %.2f\t Release"
% (key, dep, sum(map(nbytes, state["cache"].values()) / 1e6))
)
release_data(dep, state, delete=delete)
elif delete and dep not in results:
release_data(dep, state, delete=delete)
state["finished"].add(key)
state["running"].remove(key)
return state
def nested_get(ind: Union[int, List[Any]], coll: Any) -> Any:
"""Get nested index from collection.
Args:
ind: Index or nested list of indices.
coll: Collection to index into.
Returns:
Value at the given index, or a nested tuple of values if ``ind`` is a list.
Examples:
>>> nested_get(1, 'abc')
'b'
>>> nested_get([1, 0], 'abc')
('b', 'a')
>>> nested_get([[1, 0], [0, 1]], 'abc')
(('b', 'a'), ('a', 'b'))
"""
if isinstance(ind, list):
return tuple(nested_get(i, coll) for i in ind)
else:
return coll[ind]
def default_get_id():
"""Default get_id"""
return None
def default_pack_exception(e, dumps):
raise
def reraise(exc, tb=None):
if exc.__traceback__ is not tb:
raise exc.with_traceback(tb)
raise exc
def identity(x):
"""Identity function. Returns x.
>>> identity(3)
3
"""
return x
def get_async(
apply_async: Callable[..., Any],
num_workers: int,
dsk: Dict[Any, Any],
result: Union[Any, List[Any]],
cache: Optional[Dict[Any, Any]] = None,
get_id: Callable[[], Any] = default_get_id,
rerun_exceptions_locally: Optional[bool] = None,
pack_exception: Callable[..., Any] = default_pack_exception,
raise_exception: Callable[..., Any] = reraise,
callbacks: Optional[Union[Tuple[Any, ...], List[Tuple[Any, ...]]]] = None,
dumps: Callable[[Any], Any] = identity,
loads: Callable[[Any], Any] = identity,
**kwargs: Any,
) -> Any:
"""Asynchronous get function.
This is a general version of various asynchronous schedulers for dask. It
takes a an apply_async function as found on Pool objects to form a more
specific ``get`` method that walks through the dask array with parallel
workers, avoiding repeat computation and minimizing memory use.
Args:
apply_async: Asynchronous apply function as found on Pool or ThreadPool.
num_workers: The number of active tasks we should have at any one time.
dsk: A dask dictionary specifying a workflow.
result: Keys corresponding to desired data (key or list of keys).
cache: Temporary storage of results (dict-like, optional).
get_id: Function to return the worker id, takes no arguments. Examples
are `threading.current_thread` and `multiprocessing.current_process`.
rerun_exceptions_locally: Whether to rerun failing tasks in local
process to enable debugging (False by default).
pack_exception: Function to take an exception and ``dumps`` method, and
return a serialized tuple of ``(exception, traceback)`` to send
back to the scheduler. Default is to just raise the exception.
raise_exception: Function that takes an exception and a traceback, and
raises an error.
callbacks: Callbacks are passed in as tuples of length 5. Multiple sets
of callbacks may be passed in as a list of tuples. For more
information, see the dask.diagnostics documentation.
dumps: Function to serialize task data and results to communicate
between worker and parent. Defaults to identity.
loads: Inverse function of `dumps`. Defaults to identity.
**kwargs: Additional keyword arguments (unused).
Returns:
The computed result(s), with the same shape as ``result``.
See Also:
threaded.get
"""
queue = Queue()
if isinstance(result, list):
result_flat = set(flatten(result))
else:
result_flat = {result}
results = set(result_flat)
dsk = dict(dsk)
with local_callbacks(callbacks) as callbacks:
_, _, pretask_cbs, posttask_cbs, _ = unpack_callbacks(callbacks)
started_cbs = []
succeeded = False
# if start_state_from_dask fails, we will have something
# to pass to the final block.
state = {}
try:
for cb in callbacks:
if cb[0]:
cb[0](dsk)
started_cbs.append(cb)
keyorder = order(dsk)
state = start_state_from_dask(dsk, cache=cache, sortkey=keyorder.get)
for _, start_state, _, _, _ in callbacks:
if start_state:
start_state(dsk, state)
if rerun_exceptions_locally is None:
rerun_exceptions_locally = config.get("rerun_exceptions_locally", False)
if state["waiting"] and not state["ready"]:
raise ValueError("Found no accessible jobs in dask")
def fire_task():
"""Fire off a task to the thread pool"""
# Choose a good task to compute
key = state["ready"].pop()
state["running"].add(key)
for f in pretask_cbs:
f(key, dsk, state)
# Prep data to send
data = {dep: state["cache"][dep] for dep in get_dependencies(dsk, key)}
# Submit
apply_async(
execute_task,
args=(
key,
dumps((dsk[key], data)),
dumps,
loads,
get_id,
pack_exception,
),
callback=queue.put,
)
# Seed initial tasks into the thread pool
while state["ready"] and len(state["running"]) < num_workers:
fire_task()
# Main loop, wait on tasks to finish, insert new ones
while state["waiting"] or state["ready"] or state["running"]:
key, res_info, failed = queue_get(queue)
if failed:
exc, tb = loads(res_info)
if rerun_exceptions_locally:
data = {
dep: state["cache"][dep]
for dep in get_dependencies(dsk, key)
}
task = dsk[key]
task(data) # Re-execute locally
else:
raise_exception(exc, tb)
res, worker_id = loads(res_info)
state["cache"][key] = res
finish_task(dsk, key, state, results, keyorder.get)
for f in posttask_cbs:
f(key, res, dsk, state, worker_id)
while state["ready"] and len(state["running"]) < num_workers:
fire_task()
succeeded = True
finally:
for _, _, _, _, finish in started_cbs:
if finish:
finish(dsk, state, not succeeded)
return nested_get(result, state["cache"])
def apply_sync(func, args=(), kwds=None, callback=None):
"""A naive synchronous version of apply_async"""
if kwds is None:
kwds = {}
res = func(*args, **kwds)
if callback is not None:
callback(res)