Unify evaluation caching: remove redundant adapter-level cache#301
Unify evaluation caching: remove redundant adapter-level cache#301lukedhlee wants to merge 18 commits into
Conversation
Changed Files
|
9f9a325 to
b276da6
Compare
…vel cache The optimize_anything_adapter maintained its own evaluation cache alongside the core EvaluationCache in GEPAState. Both were activated by the same cache_evaluation=True flag, creating redundant caching with inconsistent identity (truncated 16-char hashes vs full SHA256, content-hashed examples vs stable DataIds). The adapter cache always intercepted first, making the core cache pure overhead. This commit removes the adapter's cache entirely and makes the core EvaluationCache the single source of truth: - Add thread-safe locking to EvaluationCache (for parallel evaluation) - Make candidate_hash() a public function in core/state.py - Remove ~90 lines of adapter cache code (_eval_cache, _candidate_hash, _cache_key, _load_cache, _save_cache_entry, disk pkl files) - Simplify _call_evaluator() to a direct pass-through - Deprecate cache_evaluation_storage config field (no longer needed) - Update tests to reflect unified caching via gepa_state.bin
Tests were directly constructing OptimizeAnythingAdapter with cache_mode="off" which no longer exists. Also updated test_refiner_with_disk_cache to check for gepa_state.bin instead of the removed fitness_cache/ directory.
…ion_storage The previous commit removed the adapter's disk cache but left no disk persistence option. This restores the cache_evaluation_storage="disk" behavior by adding write-through disk persistence directly to the core EvaluationCache: - EvaluationCache.enable_disk_cache(path): enables per-entry .pkl write-through and loads existing entries from disk on startup - optimize_anything() resolves cache_evaluation_storage and calls enable_disk_cache() when disk mode is active - initialize_gepa_state() re-enables disk cache on resumed runs - Disk entries use core's full SHA256 candidate hash + DataId (not the old adapter's truncated content hashes) - Directory changed from fitness_cache/ to eval_cache/ to avoid confusion with the old incompatible format
Write to a .tmp file first, then rename. Prevents corrupt/partial .pkl files when pickle.dump fails (e.g. non-picklable evaluator outputs).
The gepa.optimize() entry point (api.py) was creating EvaluationCache without enabling disk write-through, even when run_dir was available. Now it calls enable_disk_cache() automatically, matching the behavior of optimize_anything().
No code in this PR uses the public name. Can be made public in a future PR when cross-module hash usage is cleaned up.
The capture_traces=True evaluation in the reflective mutation proposer now checks the cache before calling adapter.evaluate(). Entries with cached trajectories are served directly, and only uncached examples (or entries without trajectories) trigger real evaluator calls. This makes total_num_evals accurate: cache hits no longer inflate the metric count, progress bar, or stop condition budget. Changes: - Add trajectory field to CachedEvaluation (backward-compat via __setstate__) - Add optional trajectory/trajectories params to put/put_batch - Proposer Step A: check cache, partition by trajectory presence, evaluate only what's needed, merge results
…CI hangs Tests in test_refiner.py, test_cache_evaluation_storage.py, and test_best_example_evals.py called real OpenRouter APIs, causing 6-hour hangs in CI (no API keys). Add an autouse conftest fixture that intercepts litellm.completion for @pytest.mark.llm_call tests, returning randomized JSON candidates. All 404 tests now pass in ~20s.
Each test file that calls optimize_anything (test_refiner.py, test_cache_evaluation_storage.py, test_best_example_evals.py) now has its own autouse _mock_litellm fixture. No changes to shared conftest or pyproject.toml — keeps the mock scoped to only the files that need it.
Move the 50-line cache-merge block from reflective_mutation.py into EvaluationCache.evaluate_batch_with_cache() with a require_trajectories flag. The proposer now calls one method instead of reimplementing cache hit/miss/merge logic inline.
…ch_with_cache, and thread safety
ac1a856 to
abcaa82
Compare
| eval_curr = self.adapter.evaluate(ctx.minibatch, ctx.curr_prog, capture_traces=True) | ||
| total_evals += eval_curr.num_metric_calls if eval_curr.num_metric_calls is not None else len(ctx.subsample_ids) | ||
| if state.evaluation_cache is not None: | ||
| eval_curr, num_evals = state.evaluation_cache.evaluate_batch_with_cache( |
There was a problem hiding this comment.
Is this thread safe? execute proposals currently may run in multi-thread
Shangyint
left a comment
There was a problem hiding this comment.
LGTM! Left two minor comments
|
|
||
| # Evaluation caching | ||
| cache_evaluation: bool = False | ||
| cache_evaluation_storage: CacheEvaluationStorage = "auto" |
There was a problem hiding this comment.
can we have some explanation here how is "auto" resolved?
There was a problem hiding this comment.
Is there any situation where run_dir is provided but the user would prefer to only keep in-memory?
|
Why is _save_to_disk is called outside the lock? |
Replace direct _cache_dir access in initialize_gepa_state with the public disk_cache_dir property.
Great questions! It's faster this way as I/O is relatively slower. And it's actually safe as everything we need is loaded/updated with lock, and we simply need to store/load them in a disk, and concurrent writes/reads for different entries go to / come from different files.
Good point! Updated. |
- TestEvaluateBatchWithCacheE2E: gepa.optimize() with counting adapter verifies cached run has fewer evaluate() calls than uncached - test_disk_cache_survives_restart: second run benefits from disk cache - Strengthen optimize_anything assertions: cache-on has fewer calls, cache-off has exactly matching calls
|
|
||
| def evaluate_batch_with_cache( | ||
| self, | ||
| candidate: dict[str, str], |
There was a problem hiding this comment.
We should support a list of candidate, as I was saying in Shangyin's PR earlier. So paired list of candidate and example ids.
…evaluate through evaluate_batch_with_cache EvaluationCache now has a single cache-aware evaluation method (evaluate_batch_with_cache). GEPAState.cached_evaluate wraps its tuple-returning evaluator into the EvaluationBatch signature and delegates to the unified method.
hash() is non-deterministic across Python runs (PYTHONHASHSEED), so the CountingAdapter could produce perfect scores (1.0) causing the engine to skip evaluations entirely, breaking the cache-reduces-calls assertion.
What changed
Before this PR, GEPA had two independent evaluation caches running simultaneously when
cache_evaluation=True:EvaluationCache(inGEPAState)_eval_cache(inOptimizeAnythingAdapter)DataIdfrom DataLoadergepa_state.bin.pklfiles infitness_cache/The adapter cache always intercepted first (inside
_call_evaluator), so the core cache never saw a miss — it was pure overhead. The two caches used different hash functions, making debugging and cross-referencing impossible.After this PR, there is one cache: the core
EvaluationCache. It now handles everything the adapter cache used to do (thread safety, disk persistence) plus new capabilities (trajectory caching).Before → After
Adapter (
optimize_anything_adapter.py)_eval_cache,_candidate_hash,_cache_key,_load_cache,_save_cache_entry, disk.pkllogic,threading.Lock)_call_evaluator()is a 3-line pass-through. All caching handled by core.Core cache (
core/state.py→EvaluationCache)threading.Lockon all reads/writes (with pickle-safe__getstate__/__setstate__)enable_disk_cache(path)for write-through.pklpersistence to{run_dir}/eval_cache/trajectoryfield onCachedEvaluation(with backward-compatible deserialization of old entries)evaluate_batch_with_cache()method withrequire_trajectoriesflagProposer (
reflective_mutation.py)capture_traces=True, then wrote results to cache without trajectories. On next iteration, cache hit → but no trajectory → had to re-evaluate anyway.evaluate_batch_with_cache(require_trajectories=True). Cached entries with trajectories are reused directly. Entries without trajectories are treated as cache misses and re-evaluated. Results (including trajectories) are stored back so the next iteration benefits.optimize_anything.pycache_mode/cache_dirto adapter constructorevaluation_cache.enable_disk_cache()on the core cache when disk mode is active. Adapter no longer needs cache config.Resume (
initialize_gepa_state)fitness_cache/directory, loaded by adaptereval_cache/directory, re-enabled on the deserializedEvaluationCacheviaenable_disk_cache()Public API
No changes.
cache_evaluationandcache_evaluation_storagework exactly as before. Theeval_cache/directory replacesfitness_cache/for disk persistence.Tests
@pytest.mark.llm_callmock fixtures (per-file_mock_litellmautouse fixtures) so refiner/cache/best-evals tests run without real API keys — fixes the 6-hour CI timeoutevaluate_batch_with_cache(all-miss/all-hit/partial/require_trajectories), thread safety (4 threads × 100 concurrent puts)