Skip to content

Commit 8d1fee3

Browse files
authored
Merge pull request #115 from KhrulkovV/perf/benchmark-squeeze
perf: 1.3-1.5x engine speedup via hot-path optimizations
2 parents 8cf1860 + 37f38ac commit 8d1fee3

10 files changed

Lines changed: 424 additions & 155 deletions

File tree

gigaevo/database/program_storage.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from typing import Any
66

77
from gigaevo.programs.program import Program
8+
from gigaevo.programs.program_state import ProgramState, validate_transition
89

910

1011
class PopulationSnapshot:
@@ -151,6 +152,43 @@ async def renew_instance_lock(self) -> bool:
151152
"""
152153
...
153154

155+
async def fast_state_transition(
156+
self, program: Program, old_state: str, new_state: str
157+
) -> None:
158+
"""Fast state transition: 2 RT (INCR + pipeline) instead of ~5 RT.
159+
160+
Safe only when the caller holds exclusive single-process ownership
161+
(e.g., asyncio.Lock in ProgramStateManager). Does NOT provide cross-process
162+
safety — assumes each program is processed by exactly one engine instance.
163+
Default falls back to atomic_state_transition.
164+
"""
165+
await self.atomic_state_transition(program, old_state, new_state)
166+
167+
async def batch_transition_state(
168+
self,
169+
programs: list[Program],
170+
old_state: str,
171+
new_state: str,
172+
) -> int:
173+
"""Batch-transition programs between states.
174+
175+
Default implementation falls back to individual transitions.
176+
Subclasses may override with pipelined operations.
177+
Returns the number of programs transitioned.
178+
"""
179+
old_enum = ProgramState(old_state)
180+
new_enum = ProgramState(new_state)
181+
count = 0
182+
for prog in programs:
183+
validate_transition(old_enum, new_enum)
184+
prog.state = new_enum
185+
await self.atomic_state_transition(prog, old_state, new_state)
186+
count += 1
187+
return count
188+
189+
async def remove_ids_from_status_set(self, status: str, ids: list[str]) -> None:
190+
"""Remove specific IDs from a status set. No-op by default."""
191+
154192
async def wait_for_activity(self, timeout: float) -> None:
155193
"""
156194
Block up to `timeout` seconds until storage observes activity (e.g., new

gigaevo/database/redis_program_storage.py

Lines changed: 104 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
)
2323
from gigaevo.exceptions import StorageError
2424
from gigaevo.programs.program import Program
25-
from gigaevo.programs.program_state import ProgramState
25+
from gigaevo.programs.program_state import ProgramState, validate_transition
2626
from gigaevo.utils.json import dumps as _dumps
2727
from gigaevo.utils.json import loads as _loads
2828
from gigaevo.utils.trackers.base import LogWriter
@@ -447,6 +447,109 @@ async def _atomic(r: aioredis.Redis) -> None:
447447

448448
await self._conn.execute("atomic_state_transition", _atomic)
449449

450+
async def fast_state_transition(
451+
self, program: Program, old_state: str, new_state: str
452+
) -> None:
453+
"""Fast state transition: 2 RT (INCR + pipeline) instead of ~5 RT.
454+
455+
Safe only when the caller holds exclusive single-process ownership
456+
(e.g., asyncio.Lock in ProgramStateManager). Does NOT provide cross-process
457+
safety — assumes each program is processed by exactly one engine instance.
458+
Unlike atomic_state_transition, does not WATCH/GET/MERGE.
459+
"""
460+
self._check_write_allowed("fast_state_transition")
461+
462+
async def _fast(r: aioredis.Redis) -> None:
463+
key = self._keys.program(program.id)
464+
counter = await r.incr(self._keys.timestamp())
465+
data = program.to_dict()
466+
data["atomic_counter"] = int(counter)
467+
468+
pipe = r.pipeline(transaction=False)
469+
pipe.set(key, _dumps(data))
470+
if old_state != new_state:
471+
pipe.srem(self._keys.status_set(old_state), program.id)
472+
pipe.sadd(self._keys.status_set(new_state), program.id)
473+
pipe.xadd(
474+
self._keys.status_stream(),
475+
{"id": program.id, "status": new_state, "event": "transition"},
476+
maxlen=STREAM_MAX_LEN,
477+
approximate=True,
478+
)
479+
await pipe.execute()
480+
481+
await self._conn.execute("fast_state_transition", _fast)
482+
483+
async def batch_transition_state(
484+
self,
485+
programs: list[Program],
486+
old_state: str,
487+
new_state: str,
488+
) -> int:
489+
"""Batch-transition programs between states using pipelined ops.
490+
491+
Much faster than individual atomic_state_transition calls for large
492+
batches (e.g., refresh phase with 5000 programs). Assumes exclusive
493+
ownership — no WATCH/MERGE needed.
494+
495+
Returns the number of programs transitioned.
496+
"""
497+
self._check_write_allowed("batch_transition_state")
498+
if not programs:
499+
return 0
500+
501+
old_enum = ProgramState(old_state)
502+
new_enum = ProgramState(new_state)
503+
for prog in programs:
504+
validate_transition(old_enum, new_enum)
505+
506+
async def _batch(r: aioredis.Redis) -> int:
507+
old_set_key = self._keys.status_set(old_state)
508+
new_set_key = self._keys.status_set(new_state)
509+
stream_key = self._keys.status_stream()
510+
ts_key = self._keys.timestamp()
511+
512+
count = 0
513+
for chunk in self._chunks(programs, MGET_CHUNK_SIZE):
514+
n = len(chunk)
515+
# Reserve N counters in one call
516+
end_counter = await r.incrby(ts_key, n)
517+
start_counter = end_counter - n + 1
518+
519+
pipe = r.pipeline(transaction=False)
520+
for i, prog in enumerate(chunk):
521+
prog.state = ProgramState(new_state)
522+
data = prog.to_dict()
523+
data["atomic_counter"] = int(start_counter + i)
524+
525+
pipe.set(self._keys.program(prog.id), _dumps(data))
526+
pipe.srem(old_set_key, prog.id)
527+
pipe.sadd(new_set_key, prog.id)
528+
529+
pipe.xadd(
530+
stream_key,
531+
{"id": "batch", "status": new_state, "event": "batch_transition"},
532+
maxlen=STREAM_MAX_LEN,
533+
approximate=True,
534+
)
535+
await pipe.execute()
536+
count += n
537+
538+
return count
539+
540+
return await self._conn.execute("batch_transition_state", _batch)
541+
542+
async def remove_ids_from_status_set(self, status: str, ids: list[str]) -> None:
543+
"""Remove specific IDs from a status set using SREM."""
544+
if not ids:
545+
return
546+
self._check_write_allowed("remove_ids_from_status_set")
547+
548+
async def _srem(r: aioredis.Redis) -> None:
549+
await r.srem(self._keys.status_set(status), *ids)
550+
551+
await self._conn.execute("remove_ids_from_status_set", _srem)
552+
450553
# --------------------- Run State (resume support) ---------------------
451554

452555
async def save_run_state(self, field: str, value: int) -> None:

gigaevo/database/state_manager.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,14 @@ async def set_program_state(
106106

107107
program.state = new_state
108108
old = old_state.value if old_state else None
109-
await self.storage.atomic_state_transition(program, old, new_state.value)
109+
# Use fast path (2 RT) when old state is known — safe because
110+
# per-program asyncio.Lock above prevents concurrent writes.
111+
if old is not None:
112+
await self.storage.fast_state_transition(program, old, new_state.value)
113+
else:
114+
await self.storage.atomic_state_transition(
115+
program, old, new_state.value
116+
)
110117

111118
logger.debug(
112119
"[ProgramStateManager] {} {} → {}",

gigaevo/evolution/engine/core.py

Lines changed: 87 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -313,14 +313,52 @@ def _format_eta(self) -> str:
313313
async def _await_idle(self) -> None:
314314
"""Block until there are no programs in QUEUED or RUNNING."""
315315
t0 = time.monotonic()
316-
while await self._has_active_dags():
316+
ghost_checked = False
317+
while True:
318+
has_active = await self._has_active_dags()
319+
if not has_active:
320+
break
321+
317322
elapsed = time.monotonic() - t0
318323
if elapsed > 30 and int(elapsed) % 60 < self.config.loop_interval:
319324
logger.info(
320325
"[EvolutionEngine] gen={} Waiting for idle ({:.0f}s elapsed)",
321326
self.metrics.total_generations,
322327
elapsed,
323328
)
329+
# Ghost safety: after 30s, verify counts with full fetch (once)
330+
if elapsed > 30 and not ghost_checked:
331+
ghost_checked = True
332+
real_q = len(
333+
await self.storage.get_all_by_status(ProgramState.QUEUED.value)
334+
)
335+
real_r = len(
336+
await self.storage.get_all_by_status(ProgramState.RUNNING.value)
337+
)
338+
if real_q == 0 and real_r == 0:
339+
# Clean up ghost IDs from status sets
340+
queued_ids = await self.storage.get_ids_by_status(
341+
ProgramState.QUEUED.value
342+
)
343+
running_ids = await self.storage.get_ids_by_status(
344+
ProgramState.RUNNING.value
345+
)
346+
if queued_ids:
347+
await self.storage.remove_ids_from_status_set(
348+
ProgramState.QUEUED.value, queued_ids
349+
)
350+
if running_ids:
351+
await self.storage.remove_ids_from_status_set(
352+
ProgramState.RUNNING.value, running_ids
353+
)
354+
ghost_count = len(queued_ids) + len(running_ids)
355+
logger.warning(
356+
"[EvolutionEngine] Ghost IDs detected — SCARD says active "
357+
"but no real programs found. Cleaned {} ghost ID(s) from "
358+
"status sets. Breaking idle wait.",
359+
ghost_count,
360+
)
361+
break
324362
await asyncio.sleep(self.config.loop_interval)
325363

326364
async def _select_elites_for_mutation(self) -> list[Program]:
@@ -359,18 +397,40 @@ async def _ingest_completed_programs(self) -> None:
359397
Programs already in the archive stay DONE (they arrived from a refresh DAG).
360398
New programs are added if accepted, otherwise discarded.
361399
"""
362-
completed = await self.storage.get_all_by_status(ProgramState.DONE.value)
363-
if not completed:
400+
# Fetch only IDs first (SMEMBERS — no deserialization), then filter
401+
# out archive programs before doing the expensive mget+deserialize.
402+
done_ids = await self.storage.get_ids_by_status(ProgramState.DONE.value)
403+
if not done_ids:
364404
logger.debug(
365405
"[EvolutionEngine] gen={} No completed programs to ingest",
366406
self.metrics.total_generations,
367407
)
368408
return
369409

410+
archive_program_ids = set(await self.strategy.get_program_ids())
411+
new_ids = [pid for pid in done_ids if pid not in archive_program_ids]
412+
413+
if not new_ids:
414+
logger.debug(
415+
"[EvolutionEngine] gen={} {} DONE programs all in archive, skipping",
416+
self.metrics.total_generations,
417+
len(done_ids),
418+
)
419+
return
420+
421+
# Only deserialize the new (non-archive) programs
422+
completed = await self.storage.mget(new_ids)
423+
# Filter to actual DONE state (mget may return stale status)
424+
completed = [p for p in completed if p.state == ProgramState.DONE]
425+
426+
if not completed:
427+
return
428+
370429
logger.info(
371-
"[EvolutionEngine] gen={} Ingest {} program(s)",
430+
"[EvolutionEngine] gen={} Ingest {} program(s) ({} in archive skipped)",
372431
self.metrics.total_generations,
373432
len(completed),
433+
len(done_ids) - len(new_ids),
374434
)
375435
logger.debug(
376436
"[EvolutionEngine] Program IDs: {}",
@@ -383,14 +443,10 @@ async def _ingest_completed_programs(self) -> None:
383443
rej_strategy = 0
384444

385445
state_tasks: list[asyncio.Task] = []
386-
archive_program_ids = set(await self.strategy.get_program_ids())
387446

388447
for prog in completed:
389448
try:
390-
if prog.id in archive_program_ids:
391-
# already in archive (arrived from a refresh DAG) — stays DONE
392-
continue
393-
elif not self.config.program_acceptor.is_accepted(prog):
449+
if not self.config.program_acceptor.is_accepted(prog):
394450
# rejected by basic checks
395451
rej_valid += 1
396452
logger.info(
@@ -460,18 +516,25 @@ async def _refresh_archive_programs(self) -> int:
460516
return 0
461517

462518
programs_to_refresh = await self.storage.mget(program_ids_to_refresh)
519+
done_programs = [p for p in programs_to_refresh if p.state == ProgramState.DONE]
463520

464-
tasks: list[asyncio.Task] = []
465-
for program in programs_to_refresh:
466-
if program.state == ProgramState.DONE:
467-
tasks.append(
468-
asyncio.create_task(self._set_state(program, ProgramState.QUEUED))
469-
)
521+
if not done_programs:
522+
return 0
470523

471-
if tasks:
472-
await asyncio.gather(*tasks, return_exceptions=True)
524+
try:
525+
count = await self.storage.batch_transition_state(
526+
done_programs,
527+
ProgramState.DONE.value,
528+
ProgramState.QUEUED.value,
529+
)
530+
except Exception as e:
531+
logger.error(
532+
"[EvolutionEngine] gen={} batch_transition_state failed: {}",
533+
self.metrics.total_generations,
534+
e,
535+
)
536+
return 0
473537

474-
count = len(tasks)
475538
if count:
476539
logger.info(
477540
"[EvolutionEngine] gen={} Submitted {} program(s) for refresh",
@@ -484,16 +547,14 @@ async def _refresh_archive_programs(self) -> int:
484547
async def _has_active_dags(self) -> bool:
485548
"""True if any programs are QUEUED or RUNNING (i.e., engine not idle).
486549
487-
Uses get_all_by_status (SMEMBERS + MGET) rather than count_by_status
488-
(raw SCARD) so that ghost IDs — set members with no backing program
489-
data — are filtered out and cannot permanently stall _await_idle().
550+
Uses count_by_status (SCARD, O(1)) for the fast path. Falls back to
551+
the expensive get_all_by_status after 30s of continuous waiting to
552+
detect ghost IDs that would otherwise stall _await_idle forever.
490553
"""
491-
queued_progs, running_progs = await asyncio.gather(
492-
self.storage.get_all_by_status(ProgramState.QUEUED.value),
493-
self.storage.get_all_by_status(ProgramState.RUNNING.value),
554+
queued, running = await asyncio.gather(
555+
self.storage.count_by_status(ProgramState.QUEUED.value),
556+
self.storage.count_by_status(ProgramState.RUNNING.value),
494557
)
495-
queued = len(queued_progs)
496-
running = len(running_progs)
497558

498559
if queued or running:
499560
current_counts = (queued, running)

0 commit comments

Comments
 (0)