Skip to content

Commit 79b72b0

Browse files
committed
Fix callable evaluator failing in subprocess workers
_prepare_evaluator() stored callable evaluators in globals() of the api module, which is inaccessible from ProcessPoolExecutor worker processes. Workers import openevolve.api fresh and the dynamic attribute doesn't exist in their memory space, causing AttributeError on every evaluation. Fix: serialize the callable to disk via cloudpickle/pickle and have the evaluator wrapper load it from the file. Also document the if __name__ == '__main__' guard requirement for macOS/Windows multiprocessing. Verified with a local LLM endpoint (Ollama) to confirm cross-process evaluation works end-to-end.
1 parent 65cbbe8 commit 79b72b0

File tree

4 files changed

+110
-20
lines changed

4 files changed

+110
-20
lines changed

README.md

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -119,14 +119,17 @@ def bubble_sort(arr):
119119
arr[j], arr[j+1] = arr[j+1], arr[j]
120120
return arr
121121

122-
result = evolve_function(
123-
bubble_sort,
124-
test_cases=[([3,1,2], [1,2,3]), ([5,2,8], [2,5,8])],
125-
iterations=50
126-
)
127-
print(f"Evolved sorting algorithm: {result.best_code}")
122+
if __name__ == '__main__':
123+
result = evolve_function(
124+
bubble_sort,
125+
test_cases=[([3,1,2], [1,2,3]), ([5,2,8], [2,5,8])],
126+
iterations=50
127+
)
128+
print(f"Evolved sorting algorithm: {result.best_code}")
128129
```
129130

131+
> **Note:** On macOS and Windows, Python uses `spawn` for multiprocessing. You must wrap evolution calls in `if __name__ == '__main__':` to avoid subprocess bootstrap errors.
132+
130133
**Prefer Docker?** See the [Installation & Setup](#installation--setup) section for Docker options.
131134

132135
## See It In Action

openevolve/api.py

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
"""
44

55
import asyncio
6+
import pickle
67
import tempfile
78
import os
89
import uuid
@@ -239,20 +240,33 @@ def _prepare_evaluator(
239240

240241
# If it's a callable, create a wrapper module
241242
if callable(evaluator):
242-
# Create a unique global name for this evaluator
243-
evaluator_id = f"_openevolve_evaluator_{uuid.uuid4().hex[:8]}"
243+
try:
244+
import cloudpickle as _pickle_mod
245+
except ImportError:
246+
_pickle_mod = pickle
247+
248+
# Serialize the callable to a file so subprocess workers can load it
249+
if temp_dir is None:
250+
temp_dir = tempfile.gettempdir()
244251

245-
# Store in globals so the wrapper can find it
246-
globals()[evaluator_id] = evaluator
252+
pickle_path = os.path.join(temp_dir, f"evaluator_{uuid.uuid4().hex[:8]}.pkl")
253+
with open(pickle_path, "wb") as pf:
254+
_pickle_mod.dump(evaluator, pf)
255+
temp_files.append(pickle_path)
247256

248257
evaluator_code = f"""
249-
# Wrapper for user-provided evaluator function
250-
import {__name__} as api_module
258+
# Wrapper for user-provided evaluator function (serialized to disk for cross-process access)
259+
import pickle
260+
261+
_cached_evaluator = None
251262
252263
def evaluate(program_path):
253-
'''Wrapper for user-provided evaluator function'''
254-
user_evaluator = getattr(api_module, '{evaluator_id}')
255-
return user_evaluator(program_path)
264+
'''Wrapper that loads the evaluator from a pickle file'''
265+
global _cached_evaluator
266+
if _cached_evaluator is None:
267+
with open({pickle_path!r}, 'rb') as f:
268+
_cached_evaluator = pickle.load(f)
269+
return _cached_evaluator(program_path)
256270
"""
257271
else:
258272
# Treat as code string

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ dependencies = [
1717
"tqdm>=4.64.0",
1818
"flask",
1919
"dacite>=1.9.2",
20+
"cloudpickle>=2.0.0",
2021
]
2122

2223
[project.optional-dependencies]

tests/test_api.py

Lines changed: 77 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -116,17 +116,17 @@ def test_prepare_evaluator_from_callable(self):
116116
"""Test _prepare_evaluator with callable function"""
117117
def my_evaluator(program_path):
118118
return {"score": 0.8, "test": "passed"}
119-
119+
120120
temp_files = []
121121
result = _prepare_evaluator(my_evaluator, self.temp_dir, temp_files)
122-
122+
123123
self.assertTrue(os.path.exists(result))
124-
self.assertEqual(len(temp_files), 1)
125-
124+
# 2 temp files: the pickle file + the wrapper .py
125+
self.assertEqual(len(temp_files), 2)
126+
126127
with open(result, 'r') as f:
127128
content = f.read()
128129
self.assertIn("def evaluate(program_path)", content)
129-
self.assertIn("user_evaluator", content)
130130

131131
def test_prepare_evaluator_from_string(self):
132132
"""Test _prepare_evaluator with code string"""
@@ -278,5 +278,77 @@ def test_run_evolution_cleanup_false(self):
278278
mock_async.assert_called_once()
279279

280280

281+
class TestEvaluatorCrossProcess(unittest.TestCase):
282+
"""Test that callable evaluators work across process boundaries"""
283+
284+
def setUp(self):
285+
self.temp_dir = tempfile.mkdtemp()
286+
287+
def tearDown(self):
288+
import shutil
289+
shutil.rmtree(self.temp_dir, ignore_errors=True)
290+
291+
def test_callable_evaluator_works_in_subprocess(self):
292+
"""Test that a callable evaluator serialized by _prepare_evaluator
293+
can be loaded and executed in a separate process (simulating
294+
ProcessPoolExecutor workers)."""
295+
def my_evaluator(program_path):
296+
return {"score": 0.42, "passed": True}
297+
298+
temp_files = []
299+
eval_file = _prepare_evaluator(my_evaluator, self.temp_dir, temp_files)
300+
301+
# Load and run the evaluator in a subprocess — this is what
302+
# process_parallel.py workers do.
303+
import subprocess, sys, json
304+
result = subprocess.run(
305+
[
306+
sys.executable, "-c",
307+
f"""
308+
import importlib.util, json, sys
309+
spec = importlib.util.spec_from_file_location("eval_mod", {eval_file!r})
310+
mod = importlib.util.module_from_spec(spec)
311+
spec.loader.exec_module(mod)
312+
print(json.dumps(mod.evaluate("dummy_path.py")))
313+
"""
314+
],
315+
capture_output=True, text=True, timeout=10
316+
)
317+
self.assertEqual(result.returncode, 0, f"Subprocess failed: {result.stderr}")
318+
metrics = json.loads(result.stdout.strip())
319+
self.assertAlmostEqual(metrics["score"], 0.42)
320+
self.assertTrue(metrics["passed"])
321+
322+
def test_callable_evaluator_with_closure(self):
323+
"""Test that a closure (capturing local variables) works across processes."""
324+
threshold = 0.5
325+
func_name = "my_func"
326+
327+
def closure_evaluator(program_path):
328+
return {"score": threshold, "func": func_name}
329+
330+
temp_files = []
331+
eval_file = _prepare_evaluator(closure_evaluator, self.temp_dir, temp_files)
332+
333+
import subprocess, sys, json
334+
result = subprocess.run(
335+
[
336+
sys.executable, "-c",
337+
f"""
338+
import importlib.util, json
339+
spec = importlib.util.spec_from_file_location("eval_mod", {eval_file!r})
340+
mod = importlib.util.module_from_spec(spec)
341+
spec.loader.exec_module(mod)
342+
print(json.dumps(mod.evaluate("dummy.py")))
343+
"""
344+
],
345+
capture_output=True, text=True, timeout=10
346+
)
347+
self.assertEqual(result.returncode, 0, f"Subprocess failed: {result.stderr}")
348+
metrics = json.loads(result.stdout.strip())
349+
self.assertAlmostEqual(metrics["score"], 0.5)
350+
self.assertEqual(metrics["func"], "my_func")
351+
352+
281353
if __name__ == '__main__':
282354
unittest.main()

0 commit comments

Comments
 (0)