Skip to content

Commit b741ba9

Browse files
committed
feat: continuous coverage evaluation + calibration-triggered retraining
Add a background loop that re-evaluates p90 coverage on the current model and test buffer every COVERAGE_EVAL_INTERVAL_SEC, without retraining. When the EMA of |coverage - target| crosses CALIBRATION_TRIGGER_THRESHOLD for K consecutive evaluations, signal the training loop to retrain immediately rather than waiting out the full RETRAINING_INTERVAL_SEC. Disabled by default (COVERAGE_EVAL_INTERVAL_SEC=0). Implements llm-d#19. Signed-off-by: Greg Neighbors <26003+gkneighb@users.noreply.github.com>
1 parent aaafe87 commit b741ba9

1 file changed

Lines changed: 140 additions & 2 deletions

File tree

src/llm_d_latency_predictor/training_server.py

Lines changed: 140 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,17 @@ class Settings:
122122
TTFT_GATED_MODEL_PATH: str = os.getenv("LATENCY_TTFT_GATED_MODEL_PATH", "/tmp/models/ttft_gated.joblib")
123123
TPOT_GATED_MODEL_PATH: str = os.getenv("LATENCY_TPOT_GATED_MODEL_PATH", "/tmp/models/tpot_gated.joblib")
124124

125+
# Experiment C — continuous coverage evaluation + calibration-triggered retraining.
126+
# When > 0, a background loop re-evaluates coverage on the current model + test
127+
# buffer every COVERAGE_EVAL_INTERVAL_SEC. If the EMA of |coverage - quantile_alpha|
128+
# exceeds CALIBRATION_TRIGGER_THRESHOLD for CALIBRATION_TRIGGER_K consecutive
129+
# evaluations, the next retrain fires immediately instead of waiting out the
130+
# full RETRAINING_INTERVAL_SEC. Set COVERAGE_EVAL_INTERVAL_SEC=0 to disable.
131+
COVERAGE_EVAL_INTERVAL_SEC: int = int(os.getenv("LATENCY_COVERAGE_EVAL_INTERVAL_SEC", 0))
132+
CALIBRATION_TRIGGER_THRESHOLD: float = float(os.getenv("LATENCY_CALIBRATION_TRIGGER_THRESHOLD", "5.0"))
133+
CALIBRATION_TRIGGER_K: int = int(os.getenv("LATENCY_CALIBRATION_TRIGGER_K", 2))
134+
CALIBRATION_EMA_ALPHA: float = float(os.getenv("LATENCY_CALIBRATION_EMA_ALPHA", "0.3"))
135+
125136

126137
class QueueGatedModel:
127138
"""Wraps noqueue + queued sub-models into one joblib-serializable object.
@@ -320,6 +331,13 @@ def __init__(self, model_type: str = None):
320331
self.last_retrain_time = None
321332
self._shutdown_event = threading.Event()
322333
self._training_thread: threading.Thread = None
334+
# Experiment C — calibration trigger. The continuous coverage loop sets
335+
# this when |coverage - quantile_alpha| EMA crosses
336+
# CALIBRATION_TRIGGER_THRESHOLD; the training loop checks it during its
337+
# inter-retrain sleep and fires an immediate retrain rather than waiting
338+
# the rest of RETRAINING_INTERVAL_SEC.
339+
self._calibration_trigger = threading.Event()
340+
self._coverage_eval_thread: threading.Thread | None = None
323341

324342
def _get_prefix_bucket(self, prefix_score: float) -> int:
325343
"""Map prefix cache score to bucket index."""
@@ -714,6 +732,45 @@ def _train_model_with_scaling(
714732
logging.error(f"Error in _train_model_with_scaling: {e}", exc_info=True)
715733
raise
716734

735+
def evaluate_current_coverage(self) -> tuple[float | None, float | None]:
736+
"""Re-evaluate coverage on the *currently loaded* model + test buffer,
737+
without retraining. Appends to {ttft,tpot}_coverage_scores so /metrics
738+
reflects up-to-date calibration between scheduled retrains. Returns
739+
(ttft_cov, tpot_cov); either may be None if model/test data isn't ready.
740+
Quantile-objective only — no-op under mean-objective.
741+
"""
742+
if self.objective_type == ObjectiveType.MEAN:
743+
return None, None
744+
if not self.is_ready:
745+
return None, None
746+
747+
with self.lock:
748+
ttft_test = list(self.ttft_test_data)
749+
tpot_test = list(self.tpot_test_data)
750+
ttft_model = self.ttft_model
751+
tpot_model = self.tpot_model
752+
ttft_scaler = self.ttft_scaler
753+
tpot_scaler = self.tpot_scaler
754+
755+
ttft_cov: float | None = None
756+
tpot_cov: float | None = None
757+
758+
if ttft_test and ttft_model is not None:
759+
_, cov, _ = self._calculate_metrics_on_test(ttft_model, ttft_scaler, ttft_test, "ttft", "actual_ttft_ms")
760+
if cov is not None:
761+
with self.lock:
762+
self.ttft_coverage_scores.append(cov)
763+
ttft_cov = cov
764+
765+
if tpot_test and tpot_model is not None:
766+
_, cov, _ = self._calculate_metrics_on_test(tpot_model, tpot_scaler, tpot_test, "tpot", "actual_tpot_ms")
767+
if cov is not None:
768+
with self.lock:
769+
self.tpot_coverage_scores.append(cov)
770+
tpot_cov = cov
771+
772+
return ttft_cov, tpot_cov
773+
717774
def _calculate_metrics_on_test(self, model, scaler, test_data, model_name, target_col):
718775
"""Calculate metrics on test data.
719776
@@ -1824,11 +1881,85 @@ def continuous_training_loop():
18241881
predictor.train()
18251882
except Exception:
18261883
logging.error("Error in periodic retraining", exc_info=True)
1827-
if predictor._shutdown_event.wait(timeout=settings.RETRAINING_INTERVAL_SEC):
1828-
break
1884+
1885+
# Sleep the retrain interval in 1-second slices so a calibration trigger
1886+
# (set by continuous_coverage_loop) can short-circuit the wait. Shutdown
1887+
# still takes precedence.
1888+
slept = 0.0
1889+
slice_sec = 1.0
1890+
while slept < settings.RETRAINING_INTERVAL_SEC:
1891+
if predictor._shutdown_event.is_set():
1892+
logging.info("Training loop exiting (shutdown).")
1893+
return
1894+
if predictor._calibration_trigger.is_set():
1895+
predictor._calibration_trigger.clear()
1896+
logging.info(
1897+
f"Retraining triggered by calibration deviation after {slept:.1f}s of "
1898+
f"{settings.RETRAINING_INTERVAL_SEC}s scheduled interval"
1899+
)
1900+
break
1901+
time.sleep(slice_sec)
1902+
slept += slice_sec
18291903
logging.info("Training loop exiting.")
18301904

18311905

1906+
# --- Continuous Coverage Evaluation Loop (Experiment C) ---
1907+
def continuous_coverage_loop():
1908+
"""Periodically re-evaluates calibration of the currently loaded model
1909+
against the test buffer. Detects drift between scheduled retrains and
1910+
sets _calibration_trigger when an EMA of |coverage - quantile_alpha|
1911+
crosses CALIBRATION_TRIGGER_THRESHOLD for K consecutive evaluations.
1912+
No-op when COVERAGE_EVAL_INTERVAL_SEC <= 0.
1913+
"""
1914+
if settings.COVERAGE_EVAL_INTERVAL_SEC <= 0:
1915+
logging.info("Continuous coverage evaluation disabled (COVERAGE_EVAL_INTERVAL_SEC=0).")
1916+
return
1917+
1918+
# Wait long enough for the first train() to populate models + initial coverage.
1919+
time.sleep(15)
1920+
target_pct = settings.QUANTILE_ALPHA * 100
1921+
alpha = settings.CALIBRATION_EMA_ALPHA
1922+
ttft_dev_ema = 0.0
1923+
tpot_dev_ema = 0.0
1924+
consecutive_bad = 0
1925+
1926+
logging.info(
1927+
f"Continuous coverage loop started "
1928+
f"(eval_interval={settings.COVERAGE_EVAL_INTERVAL_SEC}s, "
1929+
f"target={target_pct:.0f}%, threshold={settings.CALIBRATION_TRIGGER_THRESHOLD:.2f}pp, "
1930+
f"k={settings.CALIBRATION_TRIGGER_K}, ema_alpha={alpha:.2f})"
1931+
)
1932+
1933+
while not predictor._shutdown_event.is_set():
1934+
try:
1935+
ttft_cov, tpot_cov = predictor.evaluate_current_coverage()
1936+
if ttft_cov is not None:
1937+
ttft_dev_ema = (1 - alpha) * ttft_dev_ema + alpha * abs(ttft_cov - target_pct)
1938+
if tpot_cov is not None:
1939+
tpot_dev_ema = (1 - alpha) * tpot_dev_ema + alpha * abs(tpot_cov - target_pct)
1940+
max_dev = max(ttft_dev_ema, tpot_dev_ema)
1941+
if max_dev > settings.CALIBRATION_TRIGGER_THRESHOLD:
1942+
consecutive_bad += 1
1943+
logging.info(
1944+
f"Coverage drift: ttft_cov={ttft_cov} tpot_cov={tpot_cov} "
1945+
f"ttft_dev_ema={ttft_dev_ema:.2f} tpot_dev_ema={tpot_dev_ema:.2f} "
1946+
f"consecutive_bad={consecutive_bad}/{settings.CALIBRATION_TRIGGER_K}"
1947+
)
1948+
if consecutive_bad >= settings.CALIBRATION_TRIGGER_K:
1949+
logging.warning(
1950+
f"Calibration trigger fired (max_dev={max_dev:.2f}pp > "
1951+
f"threshold={settings.CALIBRATION_TRIGGER_THRESHOLD:.2f}pp). Requesting immediate retrain."
1952+
)
1953+
predictor._calibration_trigger.set()
1954+
consecutive_bad = 0 # one trigger per drift event
1955+
else:
1956+
consecutive_bad = 0
1957+
except Exception:
1958+
logging.error("Error in continuous coverage loop", exc_info=True)
1959+
predictor._shutdown_event.wait(timeout=settings.COVERAGE_EVAL_INTERVAL_SEC)
1960+
logging.info("Continuous coverage loop exiting.")
1961+
1962+
18321963
# --- FastAPI Events ---
18331964
@app.on_event("startup")
18341965
async def startup_event():
@@ -1839,6 +1970,13 @@ async def startup_event():
18391970
t.start()
18401971
logging.info("Background training started.")
18411972

1973+
# Experiment C — start continuous coverage evaluation only when enabled.
1974+
if settings.COVERAGE_EVAL_INTERVAL_SEC > 0:
1975+
ct = threading.Thread(target=continuous_coverage_loop, daemon=True)
1976+
predictor._coverage_eval_thread = ct
1977+
ct.start()
1978+
logging.info("Continuous coverage evaluation thread started.")
1979+
18421980

18431981
@app.on_event("shutdown")
18441982
async def shutdown_event():

0 commit comments

Comments
 (0)