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):
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,
):
# 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):
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 {
"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
):
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
):
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