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

267 lines
9.6 KiB
Python

import inspect
import logging
import os
import queue
from functools import partial
from numbers import Number
from typing import Any, Callable, Dict, Optional, Type
from ray.air._internal.util import RunnerThread, StartTraceback
from ray.air.constants import _ERROR_FETCH_TIMEOUT
from ray.train._internal.checkpoint_manager import _TrainingResult
from ray.train._internal.session import (
TrialInfo,
_TrainSession,
get_session,
init_session,
shutdown_session,
)
from ray.tune.execution.placement_groups import PlacementGroupFactory
from ray.tune.result import DEFAULT_METRIC, RESULT_DUPLICATE, SHOULD_CHECKPOINT
from ray.tune.trainable.trainable import Trainable
from ray.tune.utils import _detect_config_single
from ray.util.annotations import DeveloperAPI
logger = logging.getLogger(__name__)
# Time between FunctionTrainable checks when fetching
# new results after signaling the reporter to continue
NULL_MARKER = ".null_marker"
TEMP_MARKER = ".temp_marker"
@DeveloperAPI
class FunctionTrainable(Trainable):
"""Trainable that runs a user function reporting results.
This mode of execution does not support checkpoint/restore."""
_name = "func"
def setup(self, config):
init_session(
training_func=lambda: self._trainable_func(self.config),
trial_info=TrialInfo(
name=self.trial_name,
id=self.trial_id,
resources=self.trial_resources,
logdir=self._storage.trial_driver_staging_path,
driver_ip=None,
driver_node_id=None,
experiment_name=self._storage.experiment_dir_name,
),
storage=self._storage,
synchronous_result_reporting=True,
# Set all Train-specific properties to None.
world_rank=None,
local_rank=None,
node_rank=None,
local_world_size=None,
world_size=None,
dataset_shard=None,
checkpoint=None,
)
self._last_training_result: Optional[_TrainingResult] = None
def _trainable_func(self, config: Dict[str, Any]):
"""Subclasses can override this to set the trainable func."""
raise NotImplementedError
def _start(self):
def entrypoint():
try:
return self._trainable_func(self.config)
except Exception as e:
raise StartTraceback from e
# the runner thread is not started until the first call to _train
self._runner = RunnerThread(
target=entrypoint, error_queue=self._error_queue, daemon=True
)
# if not alive, try to start
self._status_reporter._start()
try:
self._runner.start()
except RuntimeError:
# If this is reached, it means the thread was started and is
# now done or has raised an exception.
pass
def step(self):
"""Implements train() for a Function API.
If the RunnerThread finishes without reporting "done",
Tune will automatically provide a magic keyword __duplicate__
along with a result with "done=True". The TrialRunner will handle the
result accordingly (see tune/tune_controller.py).
"""
session: _TrainSession = get_session()
if not session.training_started:
session.start()
training_result: Optional[_TrainingResult] = session.get_next()
if not training_result:
# The `RESULT_DUPLICATE` result should have been the last
# result reported by the session, which triggers cleanup.
raise RuntimeError(
"Should not have reached here. The TuneController should not "
"have scheduled another `train` remote call."
"It should have scheduled a `stop` instead "
"after the training function exits."
)
metrics = training_result.metrics
# This keyword appears if the train_func using the Function API
# finishes without "done=True". This duplicates the last result, but
# the TuneController will not log this result again.
# TuneController will also inject done=True to the result,
# and proceed to queue up a STOP decision for the trial.
if RESULT_DUPLICATE in metrics:
metrics[SHOULD_CHECKPOINT] = False
self._last_training_result = training_result
if training_result.checkpoint is not None:
# TODO(justinvyu): Result/checkpoint reporting can be combined.
# For now, since result/checkpoint reporting is separate, this
# special key will tell Tune to pull the checkpoint from
# the `last_training_result`.
metrics[SHOULD_CHECKPOINT] = True
return metrics
def execute(self, fn):
return fn(self)
def save_checkpoint(self, checkpoint_dir: str = ""):
if checkpoint_dir:
raise ValueError("Checkpoint dir should not be used with function API.")
# TODO(justinvyu): This currently breaks the `save_checkpoint` interface.
# TRAIN -> SAVE remote calls get processed sequentially,
# so `_last_training_result.checkpoint` holds onto the latest ckpt.
return self._last_training_result
def load_checkpoint(self, checkpoint_result: _TrainingResult):
# TODO(justinvyu): This currently breaks the `load_checkpoint` interface.
session = get_session()
session.loaded_checkpoint = checkpoint_result.checkpoint
def cleanup(self):
session = get_session()
try:
# session.finish raises any Exceptions from training.
# Do not wait for thread termination here (timeout=0).
session.finish(timeout=0)
finally:
# Check for any errors that might have been missed.
session._report_thread_runner_error()
# Shutdown session even if session.finish() raises an Exception.
shutdown_session()
def reset_config(self, new_config):
session = get_session()
# Wait for thread termination so it is save to re-use the same actor.
thread_timeout = int(os.environ.get("TUNE_FUNCTION_THREAD_TIMEOUT_S", 2))
session.finish(timeout=thread_timeout)
if session.training_thread.is_alive():
# Did not finish within timeout, reset unsuccessful.
return False
session.reset(
training_func=lambda: self._trainable_func(self.config),
trial_info=TrialInfo(
name=self.trial_name,
id=self.trial_id,
resources=self.trial_resources,
logdir=self._storage.trial_working_directory,
driver_ip=None,
driver_node_id=None,
experiment_name=self._storage.experiment_dir_name,
),
storage=self._storage,
)
self._last_result = {}
return True
def _report_thread_runner_error(self, block=False):
try:
e = self._error_queue.get(block=block, timeout=_ERROR_FETCH_TIMEOUT)
raise StartTraceback from e
except queue.Empty:
pass
@DeveloperAPI
def wrap_function(
train_func: Callable[[Any], Any], name: Optional[str] = None
) -> Type["FunctionTrainable"]:
inherit_from = (FunctionTrainable,)
if hasattr(train_func, "__mixins__"):
inherit_from = train_func.__mixins__ + inherit_from
func_args = inspect.getfullargspec(train_func).args
use_config_single = _detect_config_single(train_func)
if not use_config_single:
raise ValueError(
"Unknown argument found in the Trainable function. "
"The function args must include a single 'config' positional parameter.\n"
"Found: {}".format(func_args)
)
resources = getattr(train_func, "_resources", None)
class ImplicitFunc(*inherit_from):
_name = name or (
train_func.__name__ if hasattr(train_func, "__name__") else "func"
)
def __repr__(self):
return self._name
def _trainable_func(self, config):
fn = partial(train_func, config)
def handle_output(output):
if not output:
return
elif isinstance(output, dict):
get_session().report(output)
elif isinstance(output, Number):
get_session().report({DEFAULT_METRIC: output})
else:
raise ValueError(
"Invalid return or yield value. Either return/yield "
"a single number or a dictionary object in your "
"trainable function."
)
output = None
if inspect.isgeneratorfunction(train_func):
for output in fn():
handle_output(output)
else:
output = fn()
handle_output(output)
# If train_func returns, we need to notify the main event loop
# of the last result while avoiding double logging. This is done
# with the keyword RESULT_DUPLICATE -- see tune/tune_controller.py.
get_session().report({RESULT_DUPLICATE: True})
return output
@classmethod
def default_resource_request(
cls, config: Dict[str, Any]
) -> Optional[PlacementGroupFactory]:
if not isinstance(resources, PlacementGroupFactory) and callable(resources):
return resources(config)
return resources
return ImplicitFunc