Skip to content

perf(database): stop recomputing per-iteration values in the controller path - #477

Open
dexhunter wants to merge 1 commit into
algorithmicsuperintelligence:mainfrom
dexhunter:perf/controller-iteration-cpu
Open

perf(database): stop recomputing per-iteration values in the controller path#477
dexhunter wants to merge 1 commit into
algorithmicsuperintelligence:mainfrom
dexhunter:perf/controller-iteration-cpu

Conversation

@dexhunter

@dexhunter dexhunter commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

What this changes

Four operations on the controller's per-iteration path in openevolve/database.py
recompute values that are fully determined by data the database already holds. This
caches or restructures them. Behaviour is unchanged: same snapshot contents, same
MAP-Elites placement, same sampling, same island, archive and feature-map state.

  1. _fast_code_diversity re-scans both program strings on every call.
    _get_cached_diversity() compares one program against the whole reference set, so on
    each cache miss the 20 reference strings are re-scanned once per comparison and the
    program's own string is re-scanned once per reference entry. Each scan is
    len(code), code.count("\n") and set(code), all O(len(code)) and all functions of
    the string alone. A new _code_shape() memoizes the three derived values per code
    string, bounded at 2048 entries with the same insertion-order eviction the diversity
    cache already uses.

  2. Program.to_dict() calls dataclasses.asdict(self).
    asdict() re-derives the field list on every call and walks every node through
    _asdict_inner() and copy.deepcopy(). to_dict() now walks a module-level field
    tuple resolved once and uses _copy_field_value(), which has fast paths for the
    plain str/int/float/dict/list values a Program actually holds and falls
    back to asdict()/copy.deepcopy() for anything else. The returned value is
    identical, including independent copies of the nested containers, so a caller mutating
    the result still cannot reach back into the database.

  3. _cache_diversity_value() scans the whole cache to pick the eviction victim.
    min(self.diversity_cache.items(), key=…["timestamp"]) is O(cache_size) on every
    insert once the 1000-entry cache is full. Every insert carries a later timestamp than
    the one before it, and dicts preserve insertion order, so next(iter(...)) selects
    the same entry without the scan.

  4. _update_feature_stats() rebuilds its 1000-element window on every call.
    stats["values"] = stats["values"][-1000:] allocates and copies a fresh
    1000-element list each time the window is full. The list is trimmed one element at a
    time, so del values[0] produces the same window in place.

Measurement

Controller-side CPU per evolution iteration, at the shipped defaults
(population_size=1000, num_islands=5), over the production path
sample_from_island_create_database_snapshotadd
increment_island_generationshould_migrate:

CPU ms per evolution iteration
main @ 411fb59 30.14 and 33.40 (two independent runs)
this branch 5.34 and 5.28
absolute saving about 25 ms per iteration

The figure is the difference in wait4() rusage between two child processes that differ
only in how many iterations they run, so the measuring parent produces the number, not
the code under test. Each entry is the median of three repetitions over three
independently randomized 1000-program populations, holding an exclusive host CPU lock for
the timed section.

Attribution before the change, from cProfile on the same path: _fast_code_diversity
46% of the measured region (about 370 calls per iteration), Program.to_dictasdict
37% (one call per program per iteration over the whole steady-state population),
_cache_diversity_value 5.6%.

What this is and is not. This is controller-process CPU, not user-visible latency.
Every iteration also runs an LLM generation and a program evaluation, and those dominate
wall time by orders of magnitude. I am not claiming an end-to-end speedup. What it
removes is controller CPU currently spent recomputing values the database already knows,
at a call frequency of one per iteration with max_iterations defaulting to 10,000. The
absolute figure scales roughly with population_size, so it is smaller on small runs.

Correctness

  • python -m unittest discover tests (437 cases) passes with the same 7 pre-existing
    loader errors as unmodified main. The tests/integration/* modules and test_utils
    fail to import in a plain unit-test environment both before and after this change.
  • I compared to_dict() output against dataclasses.asdict() over a 1000-program
    population and the two are identical, and I checked that the result does not alias any
    database-owned dict or list.
  • I compared island membership, the archive, both feature maps, the feature statistics
    and 20 seeded sample_from_island parent/inspiration selections against an unmodified
    copy of the repository at the same commit, on the same randomized fixture. They match.
  • black --line-length 100 leaves the changed file unchanged.

One note, because it cost me several attempts: a cache that a memoizing helper depends on
has to be bound in the first statements of ProgramDatabase.__init__. __init__ calls
self.load(), which reaches log_island_status()get_island_stats()
_calculate_island_diversity()_fast_code_diversity() before the rest of __init__
has run. Binding it next to the other diversity-cache attributes raises AttributeError
and fails six of the repository's own cases. The size bound is a class attribute for the
same reason.

Deliberately left out

Each of these measured faster and is not here:

  • Caching the fitness score on Program instances, and caching to_dict() output
    keyed by program.id
    . Both are faster, and both memoize values derived from mutable
    state (program.metrics, and the program itself) with no invalidation point and no
    size bound. A stale entry would silently change MAP-Elites placement, and an unbounded
    one grows for the life of the process.
  • Restructuring _enforce_population_limit / _sample_inspirations. Both appear in
    the profile, but every rewrite I tried changed island or MAP-Elites behaviour and
    failed the repository's own tests. That is a separate change with a separate
    correctness argument.
  • Making the diversity cache a true access-ordered LRU (OrderedDict.move_to_end).
    Faster to evict, but it changes which entries survive. The current cache is
    insertion-ordered and this PR keeps that exactly.
  • Replacing the diversity heuristic itself. A cheaper approximation exists, but it
    changes the diversity values and therefore the feature binning, which is a product
    decision rather than an optimization.

The direction came from an automated optimization search over openevolve/database.py
scored on the metric above. The search record is at
https://dashboard.weco.ai/share/1kKHOUfZCqxUF6WGTepRtSouUUopojKt (10 scored candidates
over 21 attempts, from a 32.20 ms measured baseline down to 3.90 ms). I rebuilt this diff
by hand against current main and re-measured it, and I offer the link as a record of the
search, not as authorship of this patch.

The search's best candidate is not what this PR proposes. It reaches 3.90 ms partly by
caching derived code statistics against program.id in an unbounded dict, which is the
same un-invalidated-cache problem described under "Deliberately left out". This branch
keeps only the changes that are safe under mutation and bounded in memory.

…er path

_fast_code_diversity re-scans both program strings on every call, so the 20
reference-set strings are re-scanned once per comparison; Program.to_dict goes
through dataclasses.asdict, which re-derives the field list and deep-copies every
node once per program per iteration; _cache_diversity_value picks its eviction
victim with an O(cache_size) min() over timestamps; and _update_feature_stats
rebuilds its 1000-element window with a slice on every call.

Memoize the derived code shape (length, newline count, character set) per code
string, build the program dict from a field tuple resolved once with fast paths
for the plain values a Program holds, evict by dict insertion order, and trim the
feature-stats window in place.

Controller CPU per evolution iteration at the shipped defaults
(population_size=1000, num_islands=5) goes from 31.74/30.05 ms to 5.13/5.18 ms,
about 25 ms per iteration. No behaviour change: to_dict output stays identical to
dataclasses.asdict including independent copies of nested containers, and island
membership, the archive, both feature maps, the feature statistics and seeded
parent/inspiration sampling are unchanged.

Co-Authored-By: Aiden <aiden@weco.ai>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant