Skip to content

Commit 2af3979

Browse files
dexhunteraiden-weco
andcommitted
perf(database): stop recomputing per-iteration values in the controller 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>
1 parent 411fb59 commit 2af3979

1 file changed

Lines changed: 73 additions & 13 deletions

File tree

openevolve/database.py

Lines changed: 73 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,15 @@
33
"""
44

55
import base64
6+
import copy
67
import json
78
import logging
89
import os
910
import random
1011
import shutil
1112
import time
1213
import uuid
13-
from dataclasses import asdict, dataclass, field, fields
14+
from dataclasses import asdict, dataclass, field, fields, is_dataclass
1415

1516
# FileLock removed - no longer needed with threaded parallel processing
1617
from typing import Any, Dict, List, Optional, Set, Tuple, Union
@@ -40,6 +41,29 @@ def _safe_avg_metrics(metrics: Dict[str, Any]) -> float:
4041
return sum(numeric_values) / max(1, len(numeric_values)) if numeric_values else 0.0
4142

4243

44+
def _copy_field_value(value: Any) -> Any:
45+
"""
46+
Produce the same value dataclasses.asdict() would produce for one field.
47+
48+
asdict() walks every node through _asdict_inner() and copy.deepcopy(), which is
49+
a large amount of dispatch for the plain str/int/float/dict/list values a Program
50+
actually holds. The fast paths below cover those; anything else falls back to the
51+
same helpers asdict() itself would use, so the result is unchanged.
52+
"""
53+
t = type(value)
54+
if t is str or t is int or t is float or t is bool or value is None:
55+
return value
56+
if t is dict:
57+
return {k: _copy_field_value(v) for k, v in value.items()}
58+
if t is list:
59+
return [_copy_field_value(v) for v in value]
60+
if t is tuple:
61+
return tuple(_copy_field_value(v) for v in value)
62+
if is_dataclass(value) and not isinstance(value, type):
63+
return asdict(value)
64+
return copy.deepcopy(value)
65+
66+
4367
@dataclass
4468
class Program:
4569
"""Represents a program in the database"""
@@ -80,7 +104,8 @@ class Program:
80104

81105
def to_dict(self) -> Dict[str, Any]:
82106
"""Convert to dictionary representation"""
83-
return asdict(self)
107+
values = self.__dict__
108+
return {name: _copy_field_value(values[name]) for name in _PROGRAM_FIELD_NAMES}
84109

85110
@classmethod
86111
def from_dict(cls, data: Dict[str, Any]) -> "Program":
@@ -112,6 +137,11 @@ def from_dict(cls, data: Dict[str, Any]) -> "Program":
112137
return cls(**filtered_data)
113138

114139

140+
# Field order of Program, resolved once. to_dict() walks this instead of calling
141+
# dataclasses.fields() on every call.
142+
_PROGRAM_FIELD_NAMES = tuple(f.name for f in fields(Program))
143+
144+
115145
class ProgramDatabase:
116146
"""
117147
Database for storing and sampling programs during evolution
@@ -121,9 +151,18 @@ class ProgramDatabase:
121151
It also tracks the absolute best program separately to ensure it's never lost.
122152
"""
123153

154+
# Bound on _code_shape_cache. A class attribute rather than an instance one so it
155+
# is available to _fast_code_diversity from inside __init__ itself.
156+
_CODE_SHAPE_CACHE_SIZE = 2048
157+
124158
def __init__(self, config: DatabaseConfig):
125159
self.config = config
126160

161+
# Bound before anything else in __init__: load() below reaches
162+
# log_island_status() -> get_island_stats() -> _calculate_island_diversity()
163+
# -> _fast_code_diversity(), which needs this cache.
164+
self._code_shape_cache: Dict[str, Tuple[int, int, frozenset]] = {}
165+
127166
# In-memory program storage
128167
self.programs: Dict[str, Program] = {}
129168

@@ -2092,6 +2131,24 @@ def _calculate_island_diversity(self, programs: List[Program]) -> float:
20922131

20932132
return total_diversity / max(1, comparisons)
20942133

2134+
def _code_shape(self, code: str) -> Tuple[int, int, frozenset]:
2135+
"""
2136+
Length, newline count and character set of one code string, memoized.
2137+
2138+
_get_cached_diversity() compares one program against the whole reference set,
2139+
so the same reference strings are re-scanned once per comparison, and the
2140+
program's own string is re-scanned once per reference entry. Each scan is
2141+
O(len(code)); the derived values only depend on the string itself.
2142+
"""
2143+
shape = self._code_shape_cache.get(code)
2144+
if shape is None:
2145+
shape = (len(code), code.count("\n"), frozenset(code))
2146+
if len(self._code_shape_cache) >= self._CODE_SHAPE_CACHE_SIZE:
2147+
# Same insertion-order eviction the diversity cache uses
2148+
del self._code_shape_cache[next(iter(self._code_shape_cache))]
2149+
self._code_shape_cache[code] = shape
2150+
return shape
2151+
20952152
def _fast_code_diversity(self, code1: str, code2: str) -> float:
20962153
"""
20972154
Fast approximation of code diversity using simple metrics
@@ -2101,18 +2158,16 @@ def _fast_code_diversity(self, code1: str, code2: str) -> float:
21012158
if code1 == code2:
21022159
return 0.0
21032160

2161+
len1, lines1, chars1 = self._code_shape(code1)
2162+
len2, lines2, chars2 = self._code_shape(code2)
2163+
21042164
# Length difference (scaled to reasonable range)
2105-
len1, len2 = len(code1), len(code2)
21062165
length_diff = abs(len1 - len2)
21072166

21082167
# Line count difference
2109-
lines1 = code1.count("\n")
2110-
lines2 = code2.count("\n")
21112168
line_diff = abs(lines1 - lines2)
21122169

21132170
# Simple character set difference
2114-
chars1 = set(code1)
2115-
chars2 = set(code2)
21162171
char_diff = len(chars1.symmetric_difference(chars2))
21172172

21182173
# Combine metrics (scaled to match original edit distance range)
@@ -2206,9 +2261,10 @@ def _cache_diversity_value(self, code_hash: int, diversity: float) -> None:
22062261
"""Cache a diversity value with LRU eviction"""
22072262
# Check if cache is full
22082263
if len(self.diversity_cache) >= self.diversity_cache_size:
2209-
# Remove oldest entry
2210-
oldest_hash = min(self.diversity_cache.items(), key=lambda x: x[1]["timestamp"])[0]
2211-
del self.diversity_cache[oldest_hash]
2264+
# Remove oldest entry. Entries are inserted in increasing timestamp order
2265+
# and dicts preserve insertion order, so the first key is the same entry
2266+
# the previous min()-over-timestamps scan selected, without the O(n) scan.
2267+
del self.diversity_cache[next(iter(self.diversity_cache))]
22122268

22132269
# Add new entry
22142270
self.diversity_cache[code_hash] = {"value": diversity, "timestamp": time.time()}
@@ -2239,9 +2295,13 @@ def _update_feature_stats(self, feature_name: str, value: float) -> None:
22392295
stats["max"] = max(stats["max"], value)
22402296

22412297
# Keep recent values for more sophisticated scaling methods
2242-
stats["values"].append(value)
2243-
if len(stats["values"]) > 1000: # Limit memory usage
2244-
stats["values"] = stats["values"][-1000:]
2298+
values = stats["values"]
2299+
values.append(value)
2300+
if len(values) > 1000: # Limit memory usage
2301+
# The list is trimmed one element at a time, so dropping the head in place
2302+
# is the same window the [-1000:] slice produced, without allocating and
2303+
# copying a fresh 1000-element list on every call once the window is full.
2304+
del values[0]
22452305

22462306
def _scale_feature_value(self, feature_name: str, value: float) -> float:
22472307
"""

0 commit comments

Comments
 (0)