Skip to content

Commit 33f1943

Browse files
authored
Merge pull request #117 from KhrulkovV/worktree-benchmark-polish
perf: Benchmark suite + ORM-style field projection (44% collector speedup)
2 parents 8d1fee3 + 1fbbbf5 commit 33f1943

10 files changed

Lines changed: 823 additions & 19 deletions

File tree

gigaevo/database/program_storage.py

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,29 +14,43 @@ class PopulationSnapshot:
1414
Shared across all collector instances via ``storage.snapshot``.
1515
Call :meth:`bump` at phase boundaries to invalidate; collectors that
1616
see a stale epoch will re-fetch exactly once (others piggyback).
17+
18+
Single-slot cache keyed on ``(epoch, exclude)``. Works correctly when all
19+
callers within an epoch use the same exclude value (which is true in practice:
20+
EvolutionaryStatisticsCollector always uses exclude={"stage_results"}, all
21+
other callers use exclude=None). If a new caller with a different exclude
22+
value is added, the cache will thrash with no benefits -- a latent hazard
23+
that would require a dict-based multi-slot cache to fully address.
1724
"""
1825

19-
__slots__ = ("_epoch", "_cached_epoch", "_cached", "_lock")
26+
__slots__ = ("_epoch", "_cached_epoch", "_cached_exclude", "_cached", "_lock")
2027

2128
def __init__(self) -> None:
2229
self._epoch: int = 0
2330
self._cached_epoch: int = -1
31+
self._cached_exclude: frozenset[str] | None = None
2432
self._cached: list[Program] = []
2533
self._lock = asyncio.Lock()
2634

2735
def bump(self) -> None:
2836
"""Increment the epoch, invalidating the cached snapshot."""
2937
self._epoch += 1
3038

31-
async def get_all(self, storage: ProgramStorage) -> list[Program]:
32-
"""Return cached programs if epoch matches, else fetch + cache."""
33-
if self._cached_epoch == self._epoch:
39+
async def get_all(
40+
self,
41+
storage: ProgramStorage,
42+
*,
43+
exclude: frozenset[str] | None = None,
44+
) -> list[Program]:
45+
"""Return cached programs if epoch+exclude matches, else fetch + cache."""
46+
if self._cached_epoch == self._epoch and self._cached_exclude == exclude:
3447
return self._cached
3548
async with self._lock:
36-
if self._cached_epoch == self._epoch:
49+
if self._cached_epoch == self._epoch and self._cached_exclude == exclude:
3750
return self._cached
38-
programs = await storage.get_all()
51+
programs = await storage.get_all(exclude=exclude)
3952
self._cached = programs
53+
self._cached_exclude = exclude
4054
self._cached_epoch = self._epoch
4155
return programs
4256

@@ -79,10 +93,20 @@ async def publish_status_event(
7993
) -> None: ...
8094

8195
@abstractmethod
82-
async def get_all(self) -> list[Program]: ...
96+
async def get_all(self, *, exclude: frozenset[str] | None = None) -> list[Program]:
97+
"""Return all programs.
98+
99+
Args:
100+
exclude: Optional set of field names to skip during deserialization.
101+
Excluded fields get their Pydantic defaults. Implementations
102+
that don't support projection may ignore this parameter.
103+
"""
104+
...
83105

84106
@abstractmethod
85-
async def get_all_by_status(self, status: str) -> list[Program]: ...
107+
async def get_all_by_status(
108+
self, status: str, *, exclude: frozenset[str] | None = None
109+
) -> list[Program]: ...
86110

87111
@abstractmethod
88112
async def get_ids_by_status(self, status: str) -> list[str]:

gigaevo/database/redis_program_storage.py

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -98,22 +98,32 @@ def _chunks(items: Iterable[str], n: int) -> Iterable[list[str]]:
9898
yield batch
9999

100100
@staticmethod
101-
def _safe_deserialize(raw: str, ctx: str) -> Program | None:
101+
def _safe_deserialize(
102+
raw: str,
103+
ctx: str,
104+
*,
105+
exclude: frozenset[str] | None = None,
106+
) -> Program | None:
102107
try:
103-
return Program.from_dict(_loads(raw))
108+
return Program.from_dict(_loads(raw), exclude=exclude)
104109
except Exception as e:
105110
logger.warning("[RedisProgramStorage] Corrupt data in {}: {}", ctx, e)
106111
return None
107112

108113
async def _mget_by_keys(
109-
self, r: aioredis.Redis, keys: list[str], ctx: str
114+
self,
115+
r: aioredis.Redis,
116+
keys: list[str],
117+
ctx: str,
118+
*,
119+
exclude: frozenset[str] | None = None,
110120
) -> list[Program]:
111121
out: list[Program] = []
112122
for batch in self._chunks(keys, MGET_CHUNK_SIZE):
113123
blobs = await r.mget(*batch)
114124
for raw in blobs:
115125
if raw:
116-
p = self._safe_deserialize(raw, ctx)
126+
p = self._safe_deserialize(raw, ctx, exclude=exclude)
117127
if p is not None:
118128
out.append(p)
119129
return out
@@ -270,8 +280,15 @@ async def _size(r: aioredis.Redis) -> int:
270280

271281
return await self._conn.execute("size", _size)
272282

273-
async def get_all(self) -> list[Program]:
274-
"""Get all programs using SCAN + chunked MGET."""
283+
async def get_all(self, *, exclude: frozenset[str] | None = None) -> list[Program]:
284+
"""Get all programs using SCAN + chunked MGET.
285+
286+
Args:
287+
exclude: Optional set of field names to skip during deserialization
288+
(passed through to :meth:`Program.from_dict`). Excluded fields
289+
get their Pydantic defaults. Example: ``exclude=frozenset({"stage_results"})``
290+
saves ~33% deserialization cost for analytics callers.
291+
"""
275292

276293
async def _scan_then_mget(r: aioredis.Redis) -> list[Program]:
277294
keys: list[str] = []
@@ -281,7 +298,7 @@ async def _scan_then_mget(r: aioredis.Redis) -> list[Program]:
281298
keys.append(key)
282299
if not keys:
283300
return []
284-
return await self._mget_by_keys(r, keys, "get_all")
301+
return await self._mget_by_keys(r, keys, "get_all", exclude=exclude)
285302

286303
return await self._conn.execute("get_all", _scan_then_mget)
287304

@@ -340,14 +357,18 @@ async def _event(r: aioredis.Redis) -> None:
340357

341358
await self._conn.execute("publish_status_event", _event)
342359

343-
async def get_all_by_status(self, status: str) -> list[Program]:
360+
async def get_all_by_status(
361+
self, status: str, *, exclude: frozenset[str] | None = None
362+
) -> list[Program]:
344363
ids = await self._ids_for_status(status)
345364
if not ids:
346365
return []
347366

348367
async def _by_status(r: aioredis.Redis) -> list[Program]:
349368
keys = [self._keys.program(pid) for pid in ids]
350-
programs = await self._mget_by_keys(r, keys, f"get_all_by_status:{status}")
369+
programs = await self._mget_by_keys(
370+
r, keys, f"get_all_by_status:{status}", exclude=exclude
371+
)
351372
return [p for p in programs if p.state.value == status]
352373

353374
return await self._conn.execute("get_all_by_status", _by_status)

gigaevo/programs/program.py

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,13 @@ def _metadata_is_json_native(value: dict[str, Any]) -> bool:
5454
}
5555
)
5656

57+
# Fields safe to exclude in Program.from_dict: have safe Pydantic defaults.
58+
# code is required (no default), id generates new UUIDs, state resets to
59+
# QUEUED — none of those are safe to silently default.
60+
DEFERRABLE_FIELDS: frozenset[str] = frozenset(
61+
{"stage_results", "metadata", "metrics", "name"}
62+
)
63+
5764

5865
def _utcnow() -> datetime:
5966
return datetime.now(UTC)
@@ -162,8 +169,34 @@ def to_dict(self) -> dict[str, Any]:
162169
return self.model_dump(mode="json")
163170

164171
@classmethod
165-
def from_dict(cls, data: dict[str, Any]) -> Program:
172+
def from_dict(
173+
cls,
174+
data: dict[str, Any],
175+
*,
176+
exclude: frozenset[str] | None = None,
177+
) -> Program:
178+
"""Deserialize a Program from a dict (e.g. JSON-decoded Redis blob).
179+
180+
Args:
181+
data: Dict as produced by :meth:`to_dict`.
182+
exclude: Optional set of field names to skip during deserialization.
183+
Excluded fields get their Pydantic defaults (e.g. ``{}`` for
184+
``stage_results``). Works like Django ORM's ``defer()`` —
185+
callers that only need metrics/lineage can skip expensive fields
186+
such as ``stage_results`` to avoid reconstruction cost.
187+
"""
188+
if exclude:
189+
bad = exclude - DEFERRABLE_FIELDS
190+
if bad:
191+
raise ValueError(
192+
f"Cannot exclude field(s): {bad}. "
193+
f"Only {DEFERRABLE_FIELDS} may be excluded."
194+
)
195+
166196
d = dict(data)
197+
if exclude:
198+
for field in exclude:
199+
d.pop(field, None)
167200
if "metadata" in d and isinstance(d["metadata"], str):
168201
d["metadata"] = pickle_b64_deserialize(d["metadata"])
169202
if "stage_results" in d and isinstance(d["stage_results"], dict):

gigaevo/programs/stages/collector.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -360,8 +360,11 @@ def __init__(self, *, metrics_context: MetricsContext, **kwargs: Any):
360360
super().__init__(**kwargs)
361361
self.metrics_context = metrics_context
362362

363+
#: Fields the collector never accesses — skip during deserialization.
364+
_EXCLUDE = frozenset({"stage_results"})
365+
363366
async def _collect_programs(self, program: Program) -> list[Program]:
364-
return await self.storage.snapshot.get_all(self.storage)
367+
return await self.storage.snapshot.get_all(self.storage, exclude=self._EXCLUDE)
365368

366369
async def _process(
367370
self, program: Program, programs: list[Program]

tests/benchmarks/conftest.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,3 +336,36 @@ def __enter__(self):
336336

337337
def __exit__(self, *args):
338338
self.elapsed_ms = (time.perf_counter() - self._start) * 1000
339+
340+
341+
# ---------------------------------------------------------------------------
342+
# Realistic code generator
343+
# ---------------------------------------------------------------------------
344+
345+
# Reusable code snippets that compose into valid Python of varying sizes.
346+
_CODE_BLOCKS = [
347+
"def helper_{i}(data):\n return [x * {i} + 1 for x in data if x > 0]\n\n",
348+
"def transform_{i}(items):\n result = {{}}\n for idx, val in enumerate(items):\n if val is not None:\n result[f'k_{{idx}}'] = val ** 2\n return result\n\n",
349+
"class Handler_{i}:\n def __init__(self, threshold={i}):\n self.threshold = threshold\n self._cache = {{}}\n\n def process(self, values):\n out = []\n for v in values:\n if v > self.threshold:\n out.append(v - self.threshold)\n else:\n out.append(0)\n self._cache[len(out)] = out\n return out\n\n",
350+
"def analyze_{i}(matrix):\n rows = len(matrix)\n if rows == 0:\n return []\n cols = len(matrix[0])\n totals = [0.0] * cols\n for r in range(rows):\n for c in range(cols):\n totals[c] += matrix[r][c]\n return [t / rows for t in totals]\n\n",
351+
"def search_{i}(haystack, needle):\n lo, hi = 0, len(haystack) - 1\n while lo <= hi:\n mid = (lo + hi) // 2\n if haystack[mid] == needle:\n return mid\n elif haystack[mid] < needle:\n lo = mid + 1\n else:\n hi = mid - 1\n return -1\n\n",
352+
]
353+
354+
355+
def make_realistic_code(target_bytes: int) -> str:
356+
"""Generate valid Python code of approximately *target_bytes* size.
357+
358+
Uses composable function/class blocks that look like real evolved code
359+
rather than random strings. The result always parses as valid Python.
360+
Stops adding blocks once *target_bytes* is reached (never truncates mid-block).
361+
"""
362+
header = "def run_code():\n return 42\n\n"
363+
parts = [header]
364+
current_size = len(header)
365+
idx = 0
366+
while current_size < target_bytes:
367+
block = _CODE_BLOCKS[idx % len(_CODE_BLOCKS)].format(i=idx)
368+
parts.append(block)
369+
current_size += len(block)
370+
idx += 1
371+
return "".join(parts)

0 commit comments

Comments
 (0)