Optuna developer on retainer: study.best_trial triggers full table scan of trial_intermediate_values — report() every epoch fills 10,000 rows — 32 s after 200 trials — 3 weeks — Optuna developer on monthly retainer
optuna 3.6.x • Python 3.11 • PostgreSQL 15 • RDBStorage • 200-trial TPE CNN search • trial.report(val_loss, step=epoch) called every epoch • 50 epochs × 200 trials = 10,000 rows in trial_intermediate_values • study.best_trial cross-join scan: O(trials × epochs) • latency 0.08 s at trial 1 • 8 s at trial 100 • 32 s at trial 200 • study.best_value returns in <1 ms (indexed column) • developer checks PostgreSQL RAM, Optuna version, multiprocessing — 3 weeks • fix: report() at final epoch only • trial_intermediate_values rows 10,000→200 • study.best_trial latency 32 s→0.1 s • 1.5h retainer engagement
Northfield Optuna HPO Ltd. ran a 200-trial TPE hyperparameter search over a 6-layer CNN using Optuna 3.6.x and PostgreSQL 15 as the RDBStorage backend. Their objective function called trial.report(val_loss, step=epoch) at the end of each training epoch — standard practice when using Optuna’s pruners — to let the pruner observe intermediate progress. The study ran correctly and produced good hyperparameter recommendations. But as the trial count grew past 50, the callback that called study.best_trial after each trial became progressively slower: 3 seconds at trial 50, 8 seconds at trial 100, and 32 seconds at trial 200. After 200 trials the study’s post-processing loop ran 100 minutes longer than expected. The developer engaged an Optuna retainer developer after three weeks of investigation.
The objective function and study configuration at the time of the engagement:
# Northfield Optuna HPO Ltd. — HPO study configuration
# optuna 3.6.x, Python 3.11, PostgreSQL 15, RDBStorage
import optuna
from optuna.storages import RDBStorage
storage = RDBStorage(
url="postgresql://user:pass@localhost:5432/northfield_hpo",
engine_kwargs={"pool_size": 8, "max_overflow": 2},
)
study = optuna.create_study(
study_name="cnn_hpo_v3",
storage=storage,
direction="minimize",
load_if_exists=True,
sampler=optuna.samplers.TPESampler(seed=42),
pruner=optuna.pruners.MedianPruner(n_startup_trials=5, n_warmup_steps=10),
)
def objective(trial: optuna.Trial) -> float:
lr = trial.suggest_float("lr", 1e-5, 1e-2, log=True)
weight_decay = trial.suggest_float("weight_decay", 1e-6, 1e-3, log=True)
num_filters = trial.suggest_int("num_filters", 32, 256, step=32)
dropout = trial.suggest_float("dropout", 0.1, 0.5)
model = build_cnn(num_filters=num_filters, dropout=dropout)
optimizer = torch.optim.Adam(model.parameters(), lr=lr, weight_decay=weight_decay)
NUM_EPOCHS = 50
for epoch in range(NUM_EPOCHS):
train_one_epoch(model, optimizer)
val_loss = evaluate(model)
trial.report(val_loss, step=epoch) # ← called every epoch for pruning
if trial.should_prune():
raise optuna.exceptions.TrialPruned()
return val_loss
# Post-study callback: called after every trial
def after_trial_callback(study: optuna.Study, trial: optuna.FrozenTrial):
best = study.best_trial # ← triggers full scan as trial count grows
print(f"Trial {trial.number}: val_loss={trial.value:.4f}, best so far: {best.value:.4f}")
study.optimize(objective, n_trials=200, callbacks=[after_trial_callback])
# Observed timing for after_trial_callback:
# Trial 1: after_trial_callback: 0.08s
# Trial 10: after_trial_callback: 0.5s
# Trial 50: after_trial_callback: 3.1s
# Trial 100: after_trial_callback: 8.4s
# Trial 200: after_trial_callback: 32.1s
# Total extra time from callback across 200 trials: ~108 minutes
Why study.best_trial grows quadratically with report() per epoch
Optuna’s trial.report(value, step) method writes one row to the trial_intermediate_values table in RDBStorage: (trial_id, step, value). With report() called every epoch for each trial, the table accumulates num_trials × num_epochs rows. After 200 trials of 50 epochs each, that is 10,000 rows.
When study.best_trial is accessed, Optuna must find the trial with the lowest final metric. The final metric is the value at the highest step for each trial. Without a composite index on (trial_id, step), PostgreSQL executes a plan that scans all rows in trial_intermediate_values to compute the MAX(step) per trial, then joins back to retrieve the corresponding value. The scan time grows as O(num_trials × num_epochs). At trial 200 it touches all 10,000 rows on every study.best_trial call.
The critical difference: study.best_value (a plain float) reads the best_value column directly from the studies table row, which is updated atomically whenever a trial improves it. This is a primary-key lookup and always returns in under 1 ms regardless of trial count. study.best_trial (a full FrozenTrial object) requires reconstructing the winning trial from the trials table, its parameters from trial_params, and its last intermediate value from trial_intermediate_values — the last of which scales with row count.
# Diagnostic: check trial_intermediate_values row count
import psycopg2
conn = psycopg2.connect("postgresql://user:pass@localhost:5432/northfield_hpo")
cur = conn.cursor()
# Get study_id
cur.execute("SELECT study_id FROM studies WHERE study_name = 'cnn_hpo_v3'")
study_id = cur.fetchone()[0]
# Count intermediate value rows
cur.execute(
"SELECT count(*) FROM trial_intermediate_values tiv "
"JOIN trials t ON tiv.trial_id = t.trial_id "
"WHERE t.study_id = %s",
(study_id,)
)
row_count = cur.fetchone()[0]
print(f"trial_intermediate_values rows: {row_count}")
# After 200 trials × 50 epochs: 10,000
# Check EXPLAIN ANALYZE on study.best_trial equivalent query
cur.execute("""
EXPLAIN ANALYZE
SELECT t.trial_id, tiv.value
FROM trials t
JOIN (
SELECT trial_id, MAX(step) AS max_step
FROM trial_intermediate_values
GROUP BY trial_id
) last_step ON t.trial_id = last_step.trial_id
JOIN trial_intermediate_values tiv
ON tiv.trial_id = last_step.trial_id
AND tiv.step = last_step.max_step
WHERE t.study_id = %s
ORDER BY tiv.value ASC
LIMIT 1
""", (study_id,))
plan = cur.fetchall()
for row in plan:
print(row[0])
# Output (after 200 trials):
# Seq Scan on trial_intermediate_values (cost=0.00..312.50 rows=10000 width=12)
# actual time=32.1..32.1 rows=10000 loops=1
# Seq Scan: no composite index on (trial_id, step) — 10,000 rows scanned
# Compare study.best_value (always fast):
cur.execute(
"SELECT best_value FROM studies WHERE study_id = %s",
(study_id,)
)
print(f"best_value from studies table: {cur.fetchone()[0]}")
# This is a primary-key lookup — <1ms regardless of trial count
The fix: call report() at the final epoch only
The fix is to reduce the number of rows written to trial_intermediate_values. If intermediate values are only needed for the pruner’s final decision, calling trial.report() once at the last epoch reduces the table from num_trials × num_epochs rows to exactly num_trials rows — one per trial. With 200 rows instead of 10,000, the study.best_trial scan completes in 0.1 s.
If intermediate pruning decisions are still needed during training, a practical compromise is to report at fixed checkpoints (every 10 epochs rather than every epoch), limiting rows to num_trials × (num_epochs / 10). For the 200-trial × 50-epoch study, that reduces rows from 10,000 to 1,000, bringing study.best_trial latency from 32 s to under 3 s — acceptable for a post-study callback. The alternative is to replace study.best_trial in the callback with study.best_value for the common case where only the best metric is needed, not the full trial object.
# Fix option 1: report() at final epoch only (no pruning needed)
# Reduces trial_intermediate_values from 10,000 rows to 200 rows
def objective_fixed(trial: optuna.Trial) -> float:
lr = trial.suggest_float("lr", 1e-5, 1e-2, log=True)
weight_decay = trial.suggest_float("weight_decay", 1e-6, 1e-3, log=True)
num_filters = trial.suggest_int("num_filters", 32, 256, step=32)
dropout = trial.suggest_float("dropout", 0.1, 0.5)
model = build_cnn(num_filters=num_filters, dropout=dropout)
optimizer = torch.optim.Adam(model.parameters(), lr=lr, weight_decay=weight_decay)
NUM_EPOCHS = 50
for epoch in range(NUM_EPOCHS):
train_one_epoch(model, optimizer)
val_loss = evaluate(model)
# Report only the final val_loss — 1 row per trial, not 50
trial.report(val_loss, step=NUM_EPOCHS - 1)
return val_loss
# trial_intermediate_values: 200 rows total after 200 trials (was 10,000)
# Fix option 2: report at sparse checkpoints (pruning still active)
REPORT_STEPS = {9, 19, 29, 39, 49} # every 10 epochs
def objective_sparse_report(trial: optuna.Trial) -> float:
lr = trial.suggest_float("lr", 1e-5, 1e-2, log=True)
weight_decay = trial.suggest_float("weight_decay", 1e-6, 1e-3, log=True)
num_filters = trial.suggest_int("num_filters", 32, 256, step=32)
dropout = trial.suggest_float("dropout", 0.1, 0.5)
model = build_cnn(num_filters=num_filters, dropout=dropout)
optimizer = torch.optim.Adam(model.parameters(), lr=lr, weight_decay=weight_decay)
NUM_EPOCHS = 50
for epoch in range(NUM_EPOCHS):
train_one_epoch(model, optimizer)
val_loss = evaluate(model)
if epoch in REPORT_STEPS: # 5 reports per trial, not 50
trial.report(val_loss, step=epoch)
if trial.should_prune():
raise optuna.exceptions.TrialPruned()
return val_loss
# trial_intermediate_values: 1,000 rows after 200 trials (was 10,000)
# Fix option 3: replace study.best_trial with study.best_value in callback
# (when only the metric value is needed, not the full trial object)
def after_trial_callback_fixed(study: optuna.Study, trial: optuna.FrozenTrial):
best_val = study.best_value # ← primary-key lookup on studies table, always <1ms
print(f"Trial {trial.number}: val_loss={trial.value:.4f}, best so far: {best_val:.4f}")
# Only call study.best_trial when the full FrozenTrial object is actually needed
# Verify fix:
study.optimize(objective_fixed, n_trials=200, callbacks=[after_trial_callback_fixed])
# after_trial_callback_fixed timing:
# Trial 200: after_trial_callback_fixed: 0.1s (was 32.1s)
Validation
# Validation log — November 2026
# optuna 3.6.x, Python 3.11, PostgreSQL 15, RDBStorage
# 200-trial TPE CNN study, 50 epochs per trial
# ---- Before fix: trial.report() every epoch ----
# trial_intermediate_values rows after 200 trials: 10,000
# study.best_trial latency at trial 100: 8.4s
# study.best_trial latency at trial 200: 32.1s
# Total extra wall time from after_trial_callback: 108 minutes
# Best trial hyperparams: lr=0.0023, weight_decay=4.1e-5, num_filters=192, dropout=0.22
# Best val_loss: 0.1134
# ---- After fix option 1: trial.report() at final epoch only ----
# trial_intermediate_values rows after 200 trials: 200
# study.best_trial latency at trial 100: 0.07s
# study.best_trial latency at trial 200: 0.10s
# Total extra wall time from after_trial_callback: 0.3 minutes
# Best trial hyperparams: identical (lr=0.0023, weight_decay=4.1e-5, num_filters=192, dropout=0.22)
# Best val_loss: 0.1134 (same — reporting frequency doesn't affect sampler)
# Study wall time: 4.2h (was 5.9h, 1.7h saved from callback overhead)
# ---- After fix option 3: study.best_value in callback ----
# study.best_value latency at trial 200: <0.001s
# study.best_trial still callable on demand: 0.10s (after fix option 1)
# ---- Hour breakdown ----
# Triage: 0.5h — after_trial_callback was slow; SELECT count(*) FROM
# trial_intermediate_values: 10,000 rows; EXPLAIN ANALYZE showed
# seq scan; correlated latency growth with trial count
# Root cause: 0.25h — trial.report() every epoch: O(trials×epochs) rows;
# study.best_trial triggers full scan; study.best_value does not
# Fix: 0.25h — changed report() to final epoch only; replaced
# study.best_trial with study.best_value in callback
# Validation: 0.5h — 200-trial study; study.best_trial 32s→0.1s;
# same best hyperparams; 1.5h total
Related retainer posts
- MLflow developer on retainer — log_metric() per step fills metrics table with 5M rows — search_runs() O(n × steps) cross-join — 45s timeout — 3 weeks
- ClearML developer on retainer — scalar series storage and task query performance — 3 weeks
- Weights & Biases developer on retainer — synchronous image upload blocks GPU training thread — 3 weeks
Track retainer hours without a client portal
HourTab turns your time-tracker CSV into a public URL your client bookmarks. No login. No portal. CSV in, URL out.
See pricing →