Source code for pycif.plugins.obsoperators.standard.transforms.utils.dask.init_dask

import code
import logging
import os
from functools import partial
from graphlib import TopologicalSorter
from collections import defaultdict
from .......utils import path
from .......utils.check.errclass import CifError, CifImportError
from .individual_transform import do_individual_transform


def _reinit_worker_logging(level):
    """Re-establish a minimal CIF logging configuration in a multiprocessing worker.

    Each worker process starts without CIF's init_log having been called.
    Some import in dask's worker bootstrap triggers logging.basicConfig(),
    which installs a plain WARNING-level handler before any user code runs.
    This initializer replaces that with a ColorFormatter-equipped stream
    handler at the same level as the main process.

    The file handler is intentionally omitted: concurrent writes from
    multiple worker processes to the same log file are unsafe without an
    explicit multiprocessing-safe log handler (e.g. QueueHandler).
    """
    from .......utils.check.coloredlog import ColorFormatter
    root = logging.getLogger()
    for hdl in root.handlers[:]:
        root.removeHandler(hdl)
    handler = logging.StreamHandler()
    handler.setFormatter(ColorFormatter(fmt="#(level)%(message)s"))
    root.addHandler(handler)
    root.setLevel(level)


try:
    import dask
    delayed = dask.delayed
    DASK_AVAILABLE = True
    try:
        import dask.distributed  # pre-import in main thread: prevents a
        # first-time import race when xarray's get_write_lock() calls
        # _get_scheduler() from a worker thread during to_netcdf()
    except ImportError:
        pass
except ImportError:
    dask = None
    DASK_AVAILABLE = False

    def delayed(func):
        """Fallback for ``dask.delayed`` when Dask is not installed: runs eagerly.

        Args:
            func: The function that would otherwise be wrapped as a Dask
                delayed task.

        Returns:
            callable: ``func`` itself, unmodified, so decorated functions
            still work (just executed synchronously) without Dask.
        """
        return func  # passthrough: runs eagerly


[docs] def plot_task_graph(tasks, dependencies, rundir): """Render the resolved transform DAG as an interactive HTML graph. dask's own visualize() rasterizes through Graphviz, which becomes unreadable past a few dozen nodes. pyvis (vis.js) instead produces a pannable/zoomable/draggable HTML page with hover tooltips, which stays usable with the hundreds of transform tasks a typical CIF run builds. networkx and pyvis are optional (``pip install networkx pyvis`` or the ``graph`` extra): imported lazily here so their absence never breaks a run that doesn't request plotting. Args: tasks (dict): mapping of task name -> delayed object, as built by init_dask's main loop. dependencies (dict): mapping of task name -> list of precursor task names, as built by init_dask before the main loop. rundir (str): directory to write ``dask_graph.html`` into. """ try: import networkx as nx from pyvis.network import Network except ImportError: logging.warning( "plot_dask_graph is True but 'networkx' and/or 'pyvis' are not " "installed; skipping the graph plot. Install them with " "'pip install networkx pyvis' (or the 'graph' extra) to enable it." ) return direction_colors = {"forward": "#4C72B0", "adjoint": "#C44E52"} graph = nx.DiGraph() for name in tasks: ddi, transform, direction = name graph.add_node( str(name), label=f"{transform}\n{direction}", title=f"{transform} ({direction})<br>{ddi}", color=direction_colors.get(direction, "#888888"), ) for name in tasks: for dep in dependencies.get(name, []): if dep in tasks: graph.add_edge(str(dep), str(name)) net = Network( height="900px", width="100%", directed=True, notebook=False, cdn_resources="in_line", ) net.from_nx(graph) net.show_buttons(filter_=["physics"]) out_file = os.path.join(rundir, "dask_graph.html") net.write_html(out_file, notebook=False, open_browser=False) logging.info(f"Dask task graph written to {out_file}")
[docs] def init_dask( self, pipe_links, mode="fwd", do_simu=True, onlyinit=False, check_transforms=False, adj_test_threshold=10, save_debug=False, ignore_exceptions=False, ref_fwd_dir="", run_id=0, ): """Build and execute the Dask task graph for one direction of the pipe. Inverts ``pipe_links`` (which maps each node to its successors) into a ``dependencies`` mapping (node -> precursors), topologically sorts it, augments it with extra serialization dependencies coming from ``force_dump``/``force_loadout`` I/O constraints (:func:`update_dependencies_with_io`), then walks the sorted nodes to build one Dask delayed task per transform (:func:`add_transform`), skipping dead branches and nodes with no resolved dependency. Also attaches a "dry run" metadata dependency on the opposite-direction counterpart of each node so that side-effect-only initialization runs before the real computation. If ``self.plot_dask_graph`` is set, writes an interactive HTML graph of the resulting task DAG (:func:`plot_task_graph`). Finally triggers the computation of the graph's final task (``final_toobsvect`` in forward/tangent-linear mode, ``final_fromcontrol`` in adjoint mode), using the scheduler resolved from ``self.dask_mode`` (``_resolve_dask_scheduler``), restoring the root logger's level afterwards in case a worker left it altered. Args: self: The obs operator, exposing ``workdir``, ``transform_pipe``, ``datei``, ``controlvect``, ``obsvect``, ``plot_dask_graph`` and ``dask_mode``. pipe_links: Mapping of each ``(ddi, transform, direction)`` node to the list of its successor nodes, as built by :func:`..fwd_pipe.fwd_adj_pipe`. mode: Execution mode: ``"fwd"``, ``"tl"`` or ``"adj"``. do_simu: Whether to actually run the underlying model simulations. onlyinit: Whether to only initialize (not execute) every transform. check_transforms: Whether to keep debug information needed by the adjoint/tangent-linear consistency test. adj_test_threshold: Threshold passed through to transforms for the adjoint/tangent-linear consistency test. save_debug: Whether to dump debug datasets for each transform. ignore_exceptions: Whether to swallow exceptions raised by transforms unless required outputs are missing. ref_fwd_dir: Path to the reference forward run directory, used by adjoint/tangent-linear transforms that need forward outputs. run_id: Numeric id used to name this run's sub-directory under ``{self.workdir}/obsoperator/{mode}_{run_id}``. Raises: CifImportError: If Dask is not installed. Returns: None. The task graph is executed for its side effects (writing to the shared datastore via each transform's ``forward``/ ``adjoint`` method). """ # Import error if no dask if not DASK_AVAILABLE: raise CifImportError( "Dask is not available. Install it with 'pip install dask'" ) # Create of sub- working directory for the present run workdir = self.workdir rundir = f"{workdir}/obsoperator/{mode}_{run_id:04d}/" path.init_dir(rundir) # First invert the dependency graph # Pipe_links give successors, whereas we need precursors dependencies = defaultdict(list) for parent, children in pipe_links.items(): for child in children: dependencies[child].append(parent) ts = TopologicalSorter(dependencies) static_order = list(ts.static_order()) # Start/end points end_point = ("", "final_fromcontrol", "adjoint") if mode == "adj" \ else ("", "final_toobsvect", "forward") start_point = ("", "final_fromcontrol", "forward") if mode == "adj" \ else ("", "final_toobsvect", "adjoint") # Update dependencies with I/O dependencies dependencies = update_dependencies_with_io( self, static_order, end_point, start_point, dependencies ) # Initialize the dask graph tasks = {} # Now initialize the delayed functions for name in static_order: # Skip if dead branch if name != end_point: if pipe_links.get(name, []) == []: continue # Look up the actual delayed objects for the dependencies dep_objects = { d: tasks[d]["main"] for d in dependencies[name] if d in tasks} meta_objects = { d: tasks[d]["meta"] for d in dependencies[name] if d in tasks } # Stop here if no dependencies was ever initialize # That mean a dead branch if name != start_point: if dep_objects == {}: continue # Update meta with dry run transform if name != end_point: if mode == "fwd" and name[2] == "forward": dry_run = (name[0], name[1], "adjoint") meta_objects[dry_run] = tasks[dry_run]["meta"] elif mode == "adj" and name[2] == "adjoint": dry_run = (name[0], name[1], "forward") meta_objects[dry_run] = tasks[dry_run]["meta"] # Create the new task using the registry tasks[name] = add_transform( name, dep_objects, meta_objects, self.transform_pipe, self.datei, rundir, workdir, self.controlvect, self.obsvect, mode=mode, do_simu=do_simu, onlyinit=onlyinit, check_transforms=check_transforms, adj_test_threshold=adj_test_threshold, save_debug=save_debug, ignore_exceptions=ignore_exceptions, ref_fwd_dir=ref_fwd_dir, ) # Plot the task graph if requested if self.plot_dask_graph: plot_task_graph(tasks, dependencies, rundir) def _resolve_dask_scheduler(dask_mode): """Translate a user-facing Dask mode into a scheduler name Dask accepts. Args: dask_mode: A falsy value, one of the aliases ``"sync"``, ``"single-threaded"``, ``"threaded"``, ``"multiprocessing"``, or any scheduler name/object already understood by :meth:`dask.compute` (e.g. ``"threads"``, ``"processes"``, ``"synchronous"``, or a distributed client). Returns: The resolved scheduler, or None if ``dask_mode`` is falsy (use Dask's default scheduler). """ if not dask_mode: return None aliases = { "sync": "synchronous", "single-threaded": "synchronous", "threaded": "threads", "multiprocessing": "processes", } return aliases.get(dask_mode, dask_mode) scheduler = _resolve_dask_scheduler(self.dask_mode) # Now do the computation. Accept classical dask scheduler options such as # "threads", "processes", "single-threaded" / "synchronous", # as well as custom scheduler objects supported by dask.compute. if mode in ["fwd", "tl"]: final_task = tasks[("", "final_toobsvect", "forward")] else: final_task = tasks[("", "final_fromcontrol", "adjoint")] # Safety net: some transform (e.g. GribDataset's cfgrib open) may # temporarily raise the root logger's level and, on an unguarded # exception, leave it raised. Since the root logger is shared process- # wide (not thread-local), that silently kills debug/info/warning # output for the rest of the run, on every thread, well past this # compute() call. Restore the level unconditionally once dask is done. root_logger = logging.getLogger() orig_level = root_logger.level try: if scheduler is None: final_task.compute() elif scheduler == "processes": worker_log_init = partial(_reinit_worker_logging, logging.getLogger().level) final_task.compute(scheduler=scheduler, initializer=worker_log_init) else: final_task.compute(scheduler=scheduler) finally: root_logger.setLevel(orig_level) return
[docs] def entry_point(): """Return an empty result shaped like a real transform task's output. Used as the result of the virtual ``final_toobsvect``/ ``final_fromcontrol`` graph nodes, which have no actual transform to run but must still produce a value in the shape downstream/upstream tasks expect. Returns: dict: ``{"main": {"inputs": {}, "outputs": {}}, "meta": []}``. """ return { "main": {"inputs": {}, "outputs": {}}, "meta": [] }
@delayed def add_transform( name, dep_objects, meta_objects, transform_pipe, datei_ref, rundir, workdir, controlvect, obsvect, mode="fwd", init_input=None, do_simu=True, onlyinit=False, check_transforms=False, adj_test_threshold=10, save_debug=False, ignore_exceptions=False, ref_fwd_dir="", **kwargs ): """Dask-delayed task wrapping the execution of a single transform node. Registered as a :func:`dask.delayed` function (or run eagerly if Dask is unavailable, see the module-level ``delayed`` fallback). For the virtual ``final_toobsvect``/``final_fromcontrol`` nodes, returns an empty placeholder result (:func:`entry_point`) instead of running a transform. Otherwise, delegates to :func:`..individual_transform.do_individual_transform`. Args: name: ``(ddi, transform, direction)`` tuple identifying the task. dep_objects: Dict of ``{dependency_name: delayed_result["main"]}`` for this task's precursor tasks, used by Dask to infer the graph edges and pass their resolved values in. meta_objects: Dict of ``{dependency_name: delayed_result["meta"]}`` for this task's precursor tasks (plus its own dry-run counterpart when applicable). transform_pipe: Object exposing the transform instances and their shared ``mapper``. datei_ref: Reference start date of the run. rundir: Base run directory for this Dask run. workdir: Working directory passed through to the transform. controlvect: Control vector object passed through to the transform. obsvect: Observation vector object passed through to the transform. mode: Execution mode: ``"fwd"``, ``"tl"`` or ``"adj"``. init_input: Unused here, kept for interface consistency. do_simu: Whether to actually run the underlying model simulation. onlyinit: Whether to only initialize (not execute) the transform. check_transforms: Whether to keep debug information needed by the adjoint/tangent-linear consistency test. adj_test_threshold: Threshold passed through to the transform for the adjoint/tangent-linear consistency test. save_debug: Whether to dump debug datasets for this transform. ignore_exceptions: Whether to swallow exceptions raised by the transform unless required outputs are missing. ref_fwd_dir: Path to the reference forward run directory. **kwargs: Additional keyword arguments forwarded to :func:`do_individual_transform`. Returns: dict: The task result, as returned by :func:`entry_point` for virtual nodes, or by :func:`..individual_transform.do_individual_transform` otherwise. """ if name[1] in ["final_toobsvect", "final_fromcontrol"]: return entry_point() else: func = do_individual_transform return func( name, dep_objects, meta_objects, transform_pipe, datei_ref, rundir, workdir, controlvect, obsvect, mode=mode, do_simu=do_simu, onlyinit=onlyinit, check_transforms=check_transforms, adj_test_threshold=adj_test_threshold, save_debug=save_debug, ignore_exceptions=ignore_exceptions, ref_fwd_dir=ref_fwd_dir, **kwargs )
[docs] def update_dependencies_with_io( self, static_order, end_point, start_point, dependencies ): """Add extra Dask dependencies to serialize transforms sharing dump/load files. Some transforms write/read shared files on disk (via ``dump2inputs`` in forward mode when a trid is flagged ``force_dump``, or ``loadfromoutputs`` in adjoint mode when a trid is flagged ``force_loadout``). Because Dask has no visibility into that filesystem side effect, this walks every node's mapper for such flags and, for each declared dependency (``dumpin_dependencies``/``loadout_dependencies``), adds an explicit edge in ``dependencies`` between the corresponding ``dump2inputs``/``loadfromoutputs`` transform ids (resolved via ``dump2inputs_ids``/``loadfromoutputs_ids`` in the mapper) so that Dask executes them in the required order. Args: self: The obs operator, exposing ``transform_pipe.mapper``. static_order: Topologically sorted list of ``(ddi, transform, direction)`` nodes to scan. end_point: The virtual end-of-pipe node, skipped during the scan. start_point: The virtual start-of-pipe node, skipped during the scan. dependencies: Mapping of each node to the list of its precursor nodes, mutated in place with the extra I/O-derived edges. Raises: CifError: If a transform declares a dump/load dependency on another trid for which no corresponding ``dump2inputs``/``loadfromoutputs`` transform was created. Returns: dict: The same ``dependencies`` mapping, augmented in place. """ for transform in static_order: if transform in [end_point, start_point]: continue transf = transform[1] transf_mapper = self.transform_pipe.mapper[transf] # Now resolve dependencies for inputs direction = transform[2] ddi = transform[0] if direction == "forward": for trid in transf_mapper["inputs"]: if not transf_mapper["inputs"][trid].get("force_dump", False): continue # Add extra dependency to ensure serialization of dump2inputs # if required by the transform dump2inputs_deps = transf_mapper["inputs"][trid].get( 'dumpin_dependencies', []) if dump2inputs_deps == []: continue # Loop over dependencies dump2inputs_ids = transf_mapper.get("dump2inputs_ids", {}) for dep in dump2inputs_deps: if dep not in dump2inputs_ids: raise CifError( f"Transform {transform} requires dump2inputs " f"for {trid} to depend on {dep}, but no " f"dump2inputs transform was created for {dep}.") # Add dependency dependencies[(ddi, dump2inputs_ids[trid], direction)].append( (ddi, dump2inputs_ids[dep], direction)) if direction == "adjoint": for trid in transf_mapper["outputs"]: if not transf_mapper["outputs"][trid].get("force_loadout", False): continue # Add extra dependency to ensure serialization of loadfromoutputs # if required by the transform loadout_deps = transf_mapper["outputs"][trid].get( 'loadout_dependencies', []) if loadout_deps == []: continue # Loop over dependencies loadfromoutputs_ids = transf_mapper.get("loadfromoutputs_ids", {}) for dep in loadout_deps: if dep not in loadfromoutputs_ids: raise CifError( f"Transform {transform} requires loadfromoutputs " f"for {trid} to depend on {dep}, but no " f"loadfromoutputs transform was created for {dep}.") # Add dependency dependencies[(ddi, loadfromoutputs_ids[trid], direction)].append( (ddi, loadfromoutputs_ids[dep], direction)) return dependencies