Skip to content

Commit 57efa05

Browse files
saitcakmakmeta-codesync[bot]
authored andcommitted
Decouple metric fetch errors from trial status in Orchestrator (#5287)
Summary: Pull Request resolved: #5287 Keep trial execution status authoritative to runner polling when metric fetching fails. Orchestrator now leaves completed trials completed, uses MetricAvailability to count incomplete optimization-config data in the existing failure-rate policy, and handles expanded multi-metric result keys without assuming they are registered experiment metrics. This mirrors the behavior core Ax already has in the Orchestrator. This implements the core design from the abandoned RFC D98741656 and fixes T287759832. Reviewed By: ltiao, esantorella Differential Revision: D119207750 fbshipit-source-id: 040c93063541bd15388a64c7fef649fcc7d6c860
1 parent 1cb1f8a commit 57efa05

3 files changed

Lines changed: 157 additions & 212 deletions

File tree

‎ax/core/metric.py‎

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -88,10 +88,8 @@ class Metric(SortableBase, SerializationMixin):
8888
properties: Properties specific to a particular metric.
8989
"""
9090

91-
# The set of exception types stored in a ``MetchFetchE.exception`` that are
92-
# recoverable ``orchestrator._fetch_and_process_trials_data_results()``.
93-
# Exception may be a subclass of any of these types. If you want your metric
94-
# to never fail the trial, set this to ``{Exception}`` in your metric subclass.
91+
# Legacy configuration retained for compatibility with existing Metric subclasses.
92+
# Metric fetching errors no longer change trial status in Orchestrator.
9593
recoverable_exceptions: set[type[Exception]] = set()
9694
has_map_data: bool = False
9795

@@ -166,9 +164,7 @@ def period_of_new_data_after_trial_completion(cls) -> timedelta:
166164

167165
@classmethod
168166
def is_recoverable_fetch_e(cls, metric_fetch_e: MetricFetchE) -> bool:
169-
"""Checks whether the given MetricFetchE is recoverable for this metric class
170-
in ``orchestrator._fetch_and_process_trials_data_results``.
171-
"""
167+
"""Check whether the given MetricFetchE is recoverable for this metric."""
172168
if metric_fetch_e.exception is None:
173169
return False
174170
return any(

‎ax/orchestration/orchestrator.py‎

Lines changed: 87 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
from ax.core.runner import Runner
3333
from ax.core.trial import Trial
3434
from ax.core.trial_status import TrialStatus
35+
from ax.core.utils import compute_metric_availability, MetricAvailability
3536
from ax.exceptions.core import (
3637
AxError,
3738
DataRequiredError,
@@ -71,20 +72,20 @@
7172
trials attached to the underlying Ax experiment '{experiment_name}'.
7273
"""
7374
FAILURE_EXCEEDED_MSG = (
74-
"NOTE: This error is usually not caused by Ax. Please please check any trial "
75-
"evaluation processes/jobs to see why they are failing, and ensure that they "
76-
"succeed over the entire range of the parameters defined in this optimization.\n\n"
77-
"Trials are failing or being abandoned at a rate {observed_rate} that exceeds the "
78-
"tolerated trial failure rate of {f_rate} (at least {n_failed} out of first "
79-
"{n_ran} trials failed or were abandoned). Checks are triggered both at the end "
80-
"of an optimization and if at least {min_failed} trials have been "
81-
"failed/abandoned, potentially automatically due to issues with the trial."
75+
"NOTE: This error is usually not caused by Ax. Please check any trial evaluation "
76+
"processes/jobs and metric fetching infrastructure to determine why trials are "
77+
"failing or required data is missing.\n\n"
78+
"Trials are FAILED or ABANDONED, or have incomplete data for metrics in the "
79+
"optimization config, at a rate {observed_rate} that exceeds the tolerated trial "
80+
"failure rate of {f_rate} "
81+
"(at least {n_failed} out of the first {n_ran} trials). Checks are triggered both "
82+
"at the end of an optimization and after at least {min_failed} affected trials."
8283
)
8384
METRIC_FETCH_ERR_MESSAGE = (
84-
"A majority of the trial failures encountered are due to metric fetching errors. "
85-
"This could mean the metrics are flaky, broken, or misconfigured. Please check "
86-
"that the trial processes/jobs are successfully producing the expected metrics and "
87-
"that the metric is correctly configured."
85+
"One or more completed trials have incomplete data for metrics in the optimization "
86+
"config. This could mean the metrics are flaky, broken, or misconfigured. Please "
87+
"check that the trial processes/jobs are producing the expected metrics and that "
88+
"the metrics are correctly configured."
8889
)
8990

9091
EXPECTED_STAGED_MSG = (
@@ -191,17 +192,20 @@ class Orchestrator(WithDBSettingsBase, BestPointMixin):
191192
# Saved as a property so that it can be accessed after optimization is complex (ex.
192193
# for global stopping saving calculation).
193194
_num_remaining_requested_trials: int = 0
194-
# Total number of MetricFetchEs encountered during the course of optimization. Note
195-
# this is different from and may be greater than the number of trials that have
196-
# been marked either FAILED or ABANDONED due to metric fetching errors.
195+
# Total number of MetricFetchEs encountered for non-running trials during the
196+
# course of optimization.
197197
_num_metric_fetch_e_encountered: int = 0
198-
# Number of trials that have been marked either FAILED or ABANDONED due to
199-
# MetricFetchE being encountered during _fetch_and_process_trials_data_results
198+
# Number of completed trials with incomplete data for metrics in the optimization
199+
# config.
200+
# Retained under its existing name for telemetry compatibility.
200201
_num_trials_bad_due_to_err: int = 0
201202
# Keeps track of whether the allowed failure rate has been exceeded during
202203
# the optimization. If true, allows any pending trials to finish and raises
203204
# an error through self._complete_optimization.
204205
_failure_rate_has_been_exceeded: bool = False
206+
# Counts captured when the failure rate first exceeds the tolerance. Pending
207+
# trials may finish afterward, so the final error must use this snapshot.
208+
_failure_rate_exceeded_counts: tuple[int, int] | None = None
205209
# Timestamp of last optimization start time (milliseconds since Unix epoch);
206210
# recorded in each `run_n_trials`.
207211
_latest_optimization_start_timestamp: int | None = None
@@ -1079,14 +1083,15 @@ def _check_if_failure_rate_exceeded(self, force_check: bool = False) -> bool:
10791083
"""Checks if the failure rate (set in Orchestrator options) has been exceeded at
10801084
any point during the optimization.
10811085
1082-
NOTE: Both FAILED and ABANDONED trial statuses count towards the failure rate.
1086+
FAILED and ABANDONED statuses count towards the failure rate, as do completed
1087+
trials with incomplete data for metrics in the optimization config.
10831088
10841089
Args:
10851090
force_check: Indicates whether to force a failure-rate check
10861091
regardless of the number of trials that have been executed. If False
1087-
(default), the check will be skipped if the optimization has fewer than
1088-
five failed trials. If True, the check will be performed unless there
1089-
are 0 failures.
1092+
(default), the check will be skipped if the optimization has fewer
1093+
affected trials than ``min_failed_trials_for_failure_rate_check``. If
1094+
True, the check will be performed unless there are 0 affected trials.
10901095
10911096
Effect on state:
10921097
If the failure rate has been exceeded, a warning is logged and the private
@@ -1100,7 +1105,11 @@ def _check_if_failure_rate_exceeded(self, force_check: bool = False) -> bool:
11001105
if self._failure_rate_has_been_exceeded:
11011106
return True
11021107

1103-
num_bad_in_orchestrator = self._num_bad_in_orchestrator()
1108+
num_failed_or_abandoned = self._num_bad_in_orchestrator()
1109+
self._num_trials_bad_due_to_err = self._num_metric_incomplete_in_orchestrator()
1110+
num_bad_in_orchestrator = (
1111+
num_failed_or_abandoned + self._num_trials_bad_due_to_err
1112+
)
11041113
# skip check if 0 failures
11051114
if num_bad_in_orchestrator == 0:
11061115
return False
@@ -1120,15 +1129,18 @@ def _check_if_failure_rate_exceeded(self, force_check: bool = False) -> bool:
11201129
) > self.options.tolerated_trial_failure_rate
11211130

11221131
if failure_rate_exceeded:
1123-
if self._num_trials_bad_due_to_err > num_bad_in_orchestrator / 2:
1132+
if self._num_trials_bad_due_to_err > 0:
11241133
self.logger.warning(
1125-
"MetricFetchE INFO: Sweep aborted due to an exceeded error rate, "
1126-
"which was primarily caused by failure to fetch metrics. Please "
1127-
"check if anything could cause your metrics to be flaky or "
1128-
"broken."
1134+
"MetricFetchE INFO: Sweep aborted due to an exceeded error rate "
1135+
"that includes incomplete data for metrics in the optimization "
1136+
"config."
11291137
)
11301138
# NOTE: this private attribute causes `_get_max_pending_trials` to
11311139
# return zero, which causes no further trials to be scheduled.
1140+
self._failure_rate_exceeded_counts = (
1141+
num_bad_in_orchestrator,
1142+
num_ran_in_orchestrator,
1143+
)
11321144
self._failure_rate_has_been_exceeded = True
11331145
return True
11341146

@@ -1138,19 +1150,23 @@ def error_if_failure_rate_exceeded(self, force_check: bool = False) -> None:
11381150
"""Raises an exception if the failure rate (set in Orchestrator options) has
11391151
been exceeded at any point during the optimization.
11401152
1141-
NOTE: Both FAILED and ABANDONED trial statuses count towards the failure rate.
1153+
FAILED and ABANDONED statuses count towards the failure rate, as do completed
1154+
trials with incomplete data for metrics in the optimization config.
11421155
11431156
Args:
11441157
force_check: Indicates whether to force a failure-rate check
11451158
regardless of the number of trials that have been executed. If False
1146-
(default), the check will be skipped if the optimization has fewer than
1147-
five failed trials. If True, the check will be performed unless there
1148-
are 0 failures.
1159+
(default), the check will be skipped if the optimization has fewer
1160+
affected trials than ``min_failed_trials_for_failure_rate_check``. If
1161+
True, the check will be performed unless there are 0 affected trials.
11491162
"""
11501163
if self._check_if_failure_rate_exceeded(force_check=force_check):
1164+
num_bad_in_orchestrator, num_ran_in_orchestrator = none_throws(
1165+
self._failure_rate_exceeded_counts
1166+
)
11511167
raise self._get_failure_rate_exceeded_error(
1152-
num_bad_in_orchestrator=self._num_bad_in_orchestrator(),
1153-
num_ran_in_orchestrator=self._num_ran_in_orchestrator(),
1168+
num_bad_in_orchestrator=num_bad_in_orchestrator,
1169+
num_ran_in_orchestrator=num_ran_in_orchestrator,
11541170
)
11551171

11561172
def _error_if_status_quo_infeasible(self) -> None:
@@ -1403,8 +1419,8 @@ def _fetch_data_and_return_trial_indices_with_new_data(
14031419
)
14041420
return {
14051421
i
1406-
for i, results_by_metric_name in results.items()
1407-
for r in results_by_metric_name.values()
1422+
for i, results_by_metric_signature in results.items()
1423+
for r in results_by_metric_signature.values()
14081424
if r.is_ok()
14091425
}
14101426
return set()
@@ -1425,6 +1441,26 @@ def _num_ran_in_orchestrator(self) -> int:
14251441
"""Returns the number of trials that have been run by the orchestrator."""
14261442
return len(self.experiment.trials) - self._num_preexisting_trials
14271443

1444+
def _num_metric_incomplete_in_orchestrator(self) -> int:
1445+
"""Count completed trials with incomplete optimization config data."""
1446+
if self.experiment.optimization_config is None:
1447+
return 0
1448+
1449+
completed_trial_indices = [
1450+
trial.index
1451+
for trial in self.trials
1452+
if trial.status == TrialStatus.COMPLETED
1453+
and trial.index >= self._num_preexisting_trials
1454+
]
1455+
metric_availability = compute_metric_availability(
1456+
experiment=self.experiment,
1457+
trial_indices=completed_trial_indices,
1458+
)
1459+
return sum(
1460+
availability != MetricAvailability.COMPLETE
1461+
for availability in metric_availability.values()
1462+
)
1463+
14281464
def _apply_trial_statuses(
14291465
self, polled_status_to_trial_idcs: dict[TrialStatus, set[int]]
14301466
) -> set[int]:
@@ -2071,8 +2107,7 @@ def _fetch_and_process_trials_data_results(
20712107
trial_indices: Iterable[int],
20722108
) -> dict[int, dict[str, MetricFetchResult]]:
20732109
"""
2074-
Fetches results from experiment and modifies trial statuses depending on
2075-
success or failure.
2110+
Fetch results and report errors without changing trial statuses.
20762111
"""
20772112

20782113
try:
@@ -2092,8 +2127,8 @@ def _fetch_and_process_trials_data_results(
20922127
)
20932128
return {}
20942129

2095-
for trial_index, results_by_metric_name in results.items():
2096-
for metric_name, result in results_by_metric_name.items():
2130+
for trial_index, results_by_metric_signature in results.items():
2131+
for metric_signature, result in results_by_metric_signature.items():
20972132
# If the fetch call succeeded, continue.
20982133
if result.is_ok():
20992134
continue
@@ -2102,59 +2137,34 @@ def _fetch_and_process_trials_data_results(
21022137
# we do not do anything
21032138
metric_fetch_e = result.unwrap_err()
21042139

2105-
# If the metric is available while running just continue (we can try
2106-
# again later).
2107-
# NOTE: We don't need to report fetching errors in this case either
2108-
metric = self.experiment.metrics[metric_name]
21092140
status = self.experiment.trials[trial_index].status
2141+
metric = self.experiment.signature_to_metric.get(metric_signature)
21102142
if (
2111-
metric.is_available_while_running()
2112-
and status == TrialStatus.RUNNING
2143+
status == TrialStatus.RUNNING
2144+
and metric is not None
2145+
and metric.is_available_while_running()
21132146
):
21142147
self.logger.info(
2115-
f"MetricFetchE INFO: Because {metric_name} is "
2116-
f"available_while_running and trial {trial_index} is still "
2117-
"RUNNING continuing the experiment and retrying on next "
2118-
"poll..."
2148+
f"MetricFetchE INFO: Because trial {trial_index} is still "
2149+
"RUNNING, continuing after failing to fetch "
2150+
f"{metric_signature} and retrying on the next poll."
21192151
)
21202152
continue
21212153

21222154
self.logger.error(
2123-
f"Failed to fetch {metric_name} for trial {trial_index} with "
2155+
f"Failed to fetch {metric_signature} for trial {trial_index} with "
21242156
f"status {status}, found {metric_fetch_e}."
21252157
)
21262158
self._num_metric_fetch_e_encountered += 1
21272159
self._report_metric_fetch_e(
21282160
trial=self.experiment.trials[trial_index],
2129-
metric_name=metric_name,
2161+
metric_signature=metric_signature,
21302162
metric_fetch_e=metric_fetch_e,
21312163
)
21322164

2133-
# If the fetch failure was for a metric in the optimization config (an
2134-
# objective or constraint) mark the trial as failed
2135-
optimization_config = self.experiment.optimization_config
2136-
if (
2137-
optimization_config is not None
2138-
and metric_name in optimization_config.metric_names
2139-
and not self.experiment.metrics[metric_name].is_recoverable_fetch_e(
2140-
metric_fetch_e=metric_fetch_e
2141-
)
2142-
):
2143-
status = self._mark_err_trial_status(
2144-
trial=self.experiment.trials[trial_index],
2145-
metric_name=metric_name,
2146-
metric_fetch_e=metric_fetch_e,
2147-
)
2148-
self.logger.warning(
2149-
f"MetricFetchE INFO: Because {metric_name} is an objective, "
2150-
f"marking trial {trial_index} as {status}."
2151-
)
2152-
self._num_trials_bad_due_to_err += 1
2153-
continue
2154-
21552165
self.logger.info(
2156-
"MetricFetchE INFO: Continuing optimization even though "
2157-
"MetricFetchE encountered."
2166+
"MetricFetchE INFO: Trial status remains unchanged; metric data "
2167+
"availability is tracked separately."
21582168
)
21592169
continue
21602170

@@ -2163,22 +2173,11 @@ def _fetch_and_process_trials_data_results(
21632173
def _report_metric_fetch_e(
21642174
self,
21652175
trial: BaseTrial,
2166-
metric_name: str,
2176+
metric_signature: str,
21672177
metric_fetch_e: MetricFetchE,
21682178
) -> None:
21692179
pass
21702180

2171-
def _mark_err_trial_status(
2172-
self,
2173-
trial: BaseTrial,
2174-
metric_name: str | None = None,
2175-
metric_fetch_e: MetricFetchE | None = None,
2176-
) -> TrialStatus:
2177-
trial.mark_abandoned(
2178-
reason=metric_fetch_e.message if metric_fetch_e else None, unsafe=True
2179-
)
2180-
return TrialStatus.ABANDONED
2181-
21822181
def _get_failure_rate_exceeded_error(
21832182
self,
21842183
num_bad_in_orchestrator: int,
@@ -2187,7 +2186,7 @@ def _get_failure_rate_exceeded_error(
21872186
return FailureRateExceededError(
21882187
(
21892188
f"{METRIC_FETCH_ERR_MESSAGE}\n"
2190-
if self._num_trials_bad_due_to_err > num_bad_in_orchestrator / 2
2189+
if self._num_trials_bad_due_to_err > 0
21912190
else ""
21922191
)
21932192
+ " Original error message: "

0 commit comments

Comments
 (0)