mirror of
https://github.com/Stone-Red-Code/dvc.git
synced 2026-11-02 07:18:25 +01:00
424 lines
13 KiB
Python
424 lines
13 KiB
Python
import logging
|
|
import os
|
|
import pickle
|
|
from abc import ABC, abstractmethod
|
|
from contextlib import contextmanager
|
|
from functools import partial
|
|
from typing import (
|
|
TYPE_CHECKING,
|
|
Callable,
|
|
Iterable,
|
|
NamedTuple,
|
|
Optional,
|
|
Union,
|
|
)
|
|
|
|
from funcy import cached_property
|
|
|
|
from dvc.exceptions import DvcException
|
|
from dvc.path_info import PathInfo
|
|
from dvc.repo.experiments.base import (
|
|
EXEC_BASELINE,
|
|
EXEC_BRANCH,
|
|
EXEC_CHECKPOINT,
|
|
EXEC_HEAD,
|
|
EXEC_MERGE,
|
|
EXEC_NAMESPACE,
|
|
EXPS_NAMESPACE,
|
|
EXPS_STASH,
|
|
CheckpointExistsError,
|
|
ExperimentExistsError,
|
|
ExpRefInfo,
|
|
UnchangedExperimentError,
|
|
)
|
|
from dvc.scm import SCM
|
|
from dvc.stage import PipelineStage
|
|
from dvc.stage.run import CheckpointKilledError
|
|
from dvc.stage.serialize import to_lockfile
|
|
from dvc.utils import dict_sha256
|
|
from dvc.utils.fs import remove
|
|
|
|
if TYPE_CHECKING:
|
|
from multiprocessing import Queue
|
|
|
|
from dvc.scm.git import Git
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class ExecutorResult(NamedTuple):
|
|
exp_hash: Optional[str]
|
|
ref_info: Optional["ExpRefInfo"]
|
|
force: bool
|
|
|
|
|
|
class BaseExecutor(ABC):
|
|
"""Base class for executing experiments in parallel.
|
|
|
|
Args:
|
|
src: source Git SCM instance.
|
|
dvc_dir: relpath to DVC root from SCM root.
|
|
|
|
Optional keyword args:
|
|
branch: Existing git branch for this experiment.
|
|
"""
|
|
|
|
PACKED_ARGS_FILE = "repro.dat"
|
|
WARN_UNTRACKED = False
|
|
QUIET = False
|
|
|
|
def __init__(
|
|
self,
|
|
src: "Git",
|
|
dvc_dir: str,
|
|
root_dir: Optional[Union[str, PathInfo]] = None,
|
|
branch: Optional[str] = None,
|
|
name: Optional[str] = None,
|
|
**kwargs,
|
|
):
|
|
assert root_dir is not None
|
|
self._dvc_dir = dvc_dir
|
|
self.root_dir = root_dir
|
|
self._init_git(src, branch)
|
|
self.name = name
|
|
|
|
def _init_git(self, scm: "Git", branch: Optional[str] = None):
|
|
"""Init git repo and collect executor refs from the specified SCM."""
|
|
from dulwich.repo import Repo as DulwichRepo
|
|
|
|
DulwichRepo.init(os.fspath(self.root_dir))
|
|
|
|
cwd = os.getcwd()
|
|
os.chdir(self.root_dir)
|
|
try:
|
|
refspec = f"{EXEC_NAMESPACE}/"
|
|
scm.push_refspec(self.git_url, refspec, refspec)
|
|
if branch:
|
|
scm.push_refspec(self.git_url, branch, branch)
|
|
self.scm.set_ref(EXEC_BRANCH, branch, symbolic=True)
|
|
elif self.scm.get_ref(EXEC_BRANCH):
|
|
self.scm.remove_ref(EXEC_BRANCH)
|
|
|
|
if self.scm.get_ref(EXEC_CHECKPOINT):
|
|
self.scm.remove_ref(EXEC_CHECKPOINT)
|
|
|
|
# checkout EXEC_HEAD and apply EXEC_MERGE on top of it without
|
|
# committing
|
|
head = EXEC_BRANCH if branch else EXEC_HEAD
|
|
self.scm.checkout(head, detach=True)
|
|
merge_rev = self.scm.get_ref(EXEC_MERGE)
|
|
self.scm.merge(merge_rev, squash=True, commit=False)
|
|
finally:
|
|
os.chdir(cwd)
|
|
|
|
@cached_property
|
|
def scm(self):
|
|
return SCM(self.root_dir)
|
|
|
|
@property
|
|
@abstractmethod
|
|
def git_url(self) -> str:
|
|
pass
|
|
|
|
@property
|
|
def dvc_dir(self) -> str:
|
|
return os.path.join(self.root_dir, self._dvc_dir)
|
|
|
|
@staticmethod
|
|
def hash_exp(stages: Iterable["PipelineStage"]) -> str:
|
|
exp_data = {}
|
|
for stage in stages:
|
|
if isinstance(stage, PipelineStage):
|
|
exp_data.update(to_lockfile(stage))
|
|
return dict_sha256(exp_data)
|
|
|
|
def cleanup(self):
|
|
self.scm.close()
|
|
del self.scm
|
|
|
|
# TODO: come up with better way to stash repro arguments
|
|
@staticmethod
|
|
def pack_repro_args(path, *args, fs=None, extra=None, **kwargs):
|
|
dpath = os.path.dirname(path)
|
|
if fs:
|
|
open_func = fs.open
|
|
fs.makedirs(dpath)
|
|
else:
|
|
from dvc.utils.fs import makedirs
|
|
|
|
open_func = open
|
|
makedirs(dpath, exist_ok=True)
|
|
|
|
data = {"args": args, "kwargs": kwargs}
|
|
if extra is not None:
|
|
data["extra"] = extra
|
|
with open_func(path, "wb") as fobj:
|
|
pickle.dump(data, fobj)
|
|
|
|
@staticmethod
|
|
def unpack_repro_args(path):
|
|
with open(path, "rb") as fobj:
|
|
data = pickle.load(fobj)
|
|
return data["args"], data["kwargs"]
|
|
|
|
def fetch_exps(
|
|
self,
|
|
dest_scm: "Git",
|
|
force: bool = False,
|
|
on_diverged: Callable[[str, bool], None] = None,
|
|
) -> Iterable[str]:
|
|
"""Fetch reproduced experiments into the specified SCM.
|
|
|
|
Args:
|
|
dest_scm: Destination Git instance.
|
|
force: If True, diverged refs will be overwritten
|
|
on_diverged: Callback in the form on_diverged(ref, is_checkpoint)
|
|
to be called when an experiment ref has diverged.
|
|
"""
|
|
refs = []
|
|
for ref in self.scm.iter_refs(base=EXPS_NAMESPACE):
|
|
if not ref.startswith(EXEC_NAMESPACE) and ref != EXPS_STASH:
|
|
refs.append(ref)
|
|
|
|
def on_diverged_ref(orig_ref: str, new_rev: str):
|
|
orig_rev = dest_scm.get_ref(orig_ref)
|
|
if dest_scm.diff(orig_rev, new_rev):
|
|
if force:
|
|
logger.debug(
|
|
"Replacing existing experiment '%s'", orig_ref,
|
|
)
|
|
return True
|
|
if on_diverged:
|
|
checkpoint = self.scm.get_ref(EXEC_CHECKPOINT) is not None
|
|
on_diverged(orig_ref, checkpoint)
|
|
logger.debug("Reproduced existing experiment '%s'", orig_ref)
|
|
return False
|
|
|
|
# fetch experiments
|
|
dest_scm.fetch_refspecs(
|
|
self.git_url,
|
|
[f"{ref}:{ref}" for ref in refs],
|
|
on_diverged=on_diverged_ref,
|
|
force=force,
|
|
)
|
|
# update last run checkpoint (if it exists)
|
|
if self.scm.get_ref(EXEC_CHECKPOINT):
|
|
dest_scm.fetch_refspecs(
|
|
self.git_url,
|
|
[f"{EXEC_CHECKPOINT}:{EXEC_CHECKPOINT}"],
|
|
force=True,
|
|
)
|
|
return refs
|
|
|
|
@classmethod
|
|
def reproduce(
|
|
cls,
|
|
dvc_dir: Optional[str],
|
|
rev: str,
|
|
queue: Optional["Queue"] = None,
|
|
rel_cwd: Optional[str] = None,
|
|
name: Optional[str] = None,
|
|
log_level: Optional[int] = None,
|
|
) -> "ExecutorResult":
|
|
"""Run dvc repro and return the result.
|
|
|
|
Returns tuple of (exp_hash, exp_ref, force) where exp_hash is the
|
|
experiment hash (or None on error), exp_ref is the experiment ref,
|
|
and force is a bool specifying whether or not this experiment
|
|
should force overwrite any existing duplicates.
|
|
"""
|
|
from dvc.repo.checkout import checkout as dvc_checkout
|
|
from dvc.repo.reproduce import reproduce as dvc_reproduce
|
|
|
|
unchanged = []
|
|
|
|
if queue is not None:
|
|
queue.put((rev, os.getpid()))
|
|
if log_level is not None:
|
|
cls._set_log_level(log_level)
|
|
|
|
def filter_pipeline(stages):
|
|
unchanged.extend(
|
|
[stage for stage in stages if isinstance(stage, PipelineStage)]
|
|
)
|
|
|
|
exp_hash: Optional[str] = None
|
|
exp_ref: Optional["ExpRefInfo"] = None
|
|
repro_force: bool = False
|
|
|
|
with cls._repro_dvc(dvc_dir, rel_cwd) as dvc:
|
|
args, kwargs = cls._repro_args(dvc)
|
|
|
|
repro_force = kwargs.get("force", False)
|
|
logger.trace( # type: ignore[attr-defined]
|
|
"Executor repro with force = '%s'", str(repro_force)
|
|
)
|
|
|
|
# NOTE: for checkpoint experiments we handle persist outs slightly
|
|
# differently than normal:
|
|
#
|
|
# - checkpoint out may not yet exist if this is the first time this
|
|
# experiment has been run, this is not an error condition for
|
|
# experiments
|
|
# - at the start of a repro run, we need to remove the persist out
|
|
# and restore it to its last known (committed) state (which may
|
|
# be removed/does not yet exist) so that our executor workspace
|
|
# is not polluted with the (persistent) out from an unrelated
|
|
# experiment run
|
|
dvc_checkout(dvc, force=True, quiet=True, allow_missing=True)
|
|
|
|
checkpoint_func = partial(
|
|
cls.checkpoint_callback, dvc.scm, name, repro_force
|
|
)
|
|
stages = dvc_reproduce(
|
|
dvc,
|
|
*args,
|
|
on_unchanged=filter_pipeline,
|
|
checkpoint_func=checkpoint_func,
|
|
**kwargs,
|
|
)
|
|
|
|
exp_hash = cls.hash_exp(stages)
|
|
try:
|
|
cls.commit(
|
|
dvc.scm,
|
|
exp_hash,
|
|
exp_name=name,
|
|
force=repro_force,
|
|
checkpoint=any(stage.is_checkpoint for stage in stages),
|
|
)
|
|
except UnchangedExperimentError:
|
|
pass
|
|
ref = dvc.scm.get_ref(EXEC_BRANCH, follow=False)
|
|
if ref:
|
|
exp_ref = ExpRefInfo.from_ref(ref)
|
|
if cls.WARN_UNTRACKED:
|
|
untracked = dvc.scm.untracked_files()
|
|
if untracked:
|
|
logger.warning(
|
|
"The following untracked files were present in the "
|
|
"experiment directory after reproduction but will "
|
|
"not be included in experiment commits:\n"
|
|
"\t%s",
|
|
", ".join(untracked),
|
|
)
|
|
|
|
# ideally we would return stages here like a normal repro() call, but
|
|
# stages is not currently picklable and cannot be returned across
|
|
# multiprocessing calls
|
|
return ExecutorResult(exp_hash, exp_ref, repro_force)
|
|
|
|
@classmethod
|
|
@contextmanager
|
|
def _repro_dvc(cls, dvc_dir: Optional[str], rel_cwd: Optional[str]):
|
|
from dvc.repo import Repo
|
|
|
|
dvc = Repo(dvc_dir)
|
|
if cls.QUIET:
|
|
dvc.scm.quiet = cls.QUIET
|
|
if dvc_dir is not None:
|
|
old_cwd: Optional[str] = os.getcwd()
|
|
if rel_cwd:
|
|
os.chdir(os.path.join(dvc.root_dir, rel_cwd))
|
|
else:
|
|
os.chdir(dvc.root_dir)
|
|
else:
|
|
old_cwd = None
|
|
logger.debug("Running repro in '%s'", os.getcwd())
|
|
|
|
try:
|
|
yield dvc
|
|
except CheckpointKilledError:
|
|
raise
|
|
except DvcException:
|
|
logger.exception("")
|
|
raise
|
|
except Exception:
|
|
logger.exception("unexpected error")
|
|
raise
|
|
finally:
|
|
dvc.scm.close()
|
|
if old_cwd:
|
|
os.chdir(old_cwd)
|
|
|
|
@classmethod
|
|
def _repro_args(cls, dvc):
|
|
args_path = os.path.join(dvc.tmp_dir, cls.PACKED_ARGS_FILE)
|
|
if os.path.exists(args_path):
|
|
args, kwargs = cls.unpack_repro_args(args_path)
|
|
remove(args_path)
|
|
else:
|
|
args = []
|
|
kwargs = {}
|
|
return args, kwargs
|
|
|
|
@classmethod
|
|
def checkpoint_callback(
|
|
cls,
|
|
scm: "Git",
|
|
name: Optional[str],
|
|
force: bool,
|
|
unchanged: Iterable["PipelineStage"],
|
|
stages: Iterable["PipelineStage"],
|
|
):
|
|
try:
|
|
exp_hash = cls.hash_exp(list(stages) + list(unchanged))
|
|
exp_rev = cls.commit(
|
|
scm, exp_hash, exp_name=name, force=force, checkpoint=True
|
|
)
|
|
logger.info("Checkpoint experiment iteration '%s'.", exp_rev[:7])
|
|
except UnchangedExperimentError:
|
|
pass
|
|
|
|
@classmethod
|
|
def commit(
|
|
cls,
|
|
scm: "Git",
|
|
exp_hash: str,
|
|
exp_name: Optional[str] = None,
|
|
force: bool = False,
|
|
checkpoint: bool = False,
|
|
):
|
|
"""Commit stages as an experiment and return the commit SHA."""
|
|
rev = scm.get_rev()
|
|
if not scm.is_dirty():
|
|
logger.debug("No changes to commit")
|
|
raise UnchangedExperimentError(rev)
|
|
|
|
branch = scm.get_ref(EXEC_BRANCH, follow=False)
|
|
if branch:
|
|
old_ref = rev
|
|
logger.debug("Commit to current experiment branch '%s'", branch)
|
|
else:
|
|
baseline_rev = scm.get_ref(EXEC_BASELINE)
|
|
name = exp_name if exp_name else f"exp-{exp_hash[:5]}"
|
|
ref_info = ExpRefInfo(baseline_rev, name)
|
|
branch = str(ref_info)
|
|
old_ref = None
|
|
if not force and scm.get_ref(branch):
|
|
if checkpoint:
|
|
raise CheckpointExistsError(ref_info.name)
|
|
raise ExperimentExistsError(ref_info.name)
|
|
logger.debug("Commit to new experiment branch '%s'", branch)
|
|
|
|
scm.add([], update=True)
|
|
scm.commit(f"dvc: commit experiment {exp_hash}", no_verify=True)
|
|
new_rev = scm.get_rev()
|
|
scm.set_ref(branch, new_rev, old_ref=old_ref)
|
|
scm.set_ref(EXEC_BRANCH, branch, symbolic=True)
|
|
if checkpoint:
|
|
scm.set_ref(EXEC_CHECKPOINT, new_rev)
|
|
return new_rev
|
|
|
|
@staticmethod
|
|
def _set_log_level(level):
|
|
from dvc.logger import disable_other_loggers
|
|
|
|
# When executor.reproduce is run in a multiprocessing child process,
|
|
# dvc.main will not be called for that child process so we need to
|
|
# setup logging ourselves
|
|
dvc_logger = logging.getLogger("dvc")
|
|
disable_other_loggers()
|
|
if level is not None:
|
|
dvc_logger.setLevel(level)
|