compileiq 1.0.0__py3-none-win_amd64.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- compileiq/ciq.py +745 -0
- compileiq/config/const.py +25 -0
- compileiq/core/core_comms.py +217 -0
- compileiq/core/core_types.py +26 -0
- compileiq/core/executable/core-manifest.json +13 -0
- compileiq/core/executable/core-version.lock +9 -0
- compileiq/core/executable/win32/amd64/core.exe +0 -0
- compileiq/core/executable/win32/amd64/libciq.dll +0 -0
- compileiq/core/verify_core.py +293 -0
- compileiq/results.py +321 -0
- compileiq/search_spaces/base.py +185 -0
- compileiq/search_spaces/compilers.py +102 -0
- compileiq/search_spaces/manifest.py +144 -0
- compileiq/search_spaces/models.py +47 -0
- compileiq/search_spaces/resolver.py +302 -0
- compileiq/tracker.py +303 -0
- compileiq/types.py +700 -0
- compileiq/utils/_setup_files.py +117 -0
- compileiq/utils/gpu.py +145 -0
- compileiq/utils/helpers.py +151 -0
- compileiq/utils/validation.py +138 -0
- compileiq/worker.py +1115 -0
- compileiq-1.0.0.dist-info/METADATA +108 -0
- compileiq-1.0.0.dist-info/RECORD +27 -0
- compileiq-1.0.0.dist-info/WHEEL +5 -0
- compileiq-1.0.0.dist-info/licenses/LICENSE +111 -0
- compileiq-1.0.0.dist-info/licenses/NOTICE +164 -0
compileiq/ciq.py
ADDED
|
@@ -0,0 +1,745 @@
|
|
|
1
|
+
import pathlib
|
|
2
|
+
import os
|
|
3
|
+
import sys
|
|
4
|
+
import shutil
|
|
5
|
+
import socket
|
|
6
|
+
import json
|
|
7
|
+
import warnings
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from tqdm.auto import tqdm
|
|
10
|
+
from uuid import uuid4
|
|
11
|
+
from pydantic import (
|
|
12
|
+
BaseModel,
|
|
13
|
+
Field,
|
|
14
|
+
model_validator,
|
|
15
|
+
ConfigDict,
|
|
16
|
+
PrivateAttr,
|
|
17
|
+
field_validator,
|
|
18
|
+
)
|
|
19
|
+
from typing import (
|
|
20
|
+
Any,
|
|
21
|
+
Callable,
|
|
22
|
+
Dict,
|
|
23
|
+
List,
|
|
24
|
+
Optional,
|
|
25
|
+
TypeAlias,
|
|
26
|
+
cast,
|
|
27
|
+
)
|
|
28
|
+
from compileiq.utils.validation import Score, SingleScore, MultiScore
|
|
29
|
+
from compileiq.tracker import (
|
|
30
|
+
_TRACKER_TYPES_TO_CLASSES,
|
|
31
|
+
)
|
|
32
|
+
from compileiq.config.const import _CACHE_DIR, KEEP_CACHE_FILES
|
|
33
|
+
from compileiq.types import (
|
|
34
|
+
BaseTracker,
|
|
35
|
+
ParamArg,
|
|
36
|
+
TrackerTypes,
|
|
37
|
+
Worker,
|
|
38
|
+
WorkerTypes,
|
|
39
|
+
SearchConfiguration,
|
|
40
|
+
InternalSearchConfiguration,
|
|
41
|
+
TrackerConfig,
|
|
42
|
+
DefaultTrackerConfig,
|
|
43
|
+
)
|
|
44
|
+
from compileiq.search_spaces.compilers import SearchSpaceProvider
|
|
45
|
+
from compileiq.results import SearchResult
|
|
46
|
+
from compileiq.core.core_comms import CoreIPC, initialize_socket
|
|
47
|
+
from compileiq.core.core_types import (
|
|
48
|
+
ParameterSet,
|
|
49
|
+
CompletionMessage,
|
|
50
|
+
EvaluatedParamResponse,
|
|
51
|
+
ResponseTemplate,
|
|
52
|
+
)
|
|
53
|
+
from compileiq.utils._setup_files import (
|
|
54
|
+
setup_legacy_search_config,
|
|
55
|
+
setup_search_space,
|
|
56
|
+
get_core_filepaths,
|
|
57
|
+
)
|
|
58
|
+
from compileiq.utils.helpers import (
|
|
59
|
+
restore_nested_search_space,
|
|
60
|
+
_decode_from_core,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
SearchSpaceInput: TypeAlias = (
|
|
64
|
+
Dict[str, Any]
|
|
65
|
+
| pathlib.Path
|
|
66
|
+
| List[Dict | pathlib.Path | SearchSpaceProvider]
|
|
67
|
+
| SearchSpaceProvider
|
|
68
|
+
)
|
|
69
|
+
SearchConfigInput: TypeAlias = Dict[str, Any] | SearchConfiguration | pathlib.Path
|
|
70
|
+
ResolvedSearchSpaceInput: TypeAlias = Dict[str, Any] | pathlib.Path | List[Dict | pathlib.Path]
|
|
71
|
+
SearchSpaceResolutionMetadataDict: TypeAlias = dict[str, str | int | None]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _expand_objective_values(
|
|
75
|
+
value: SingleScore | MultiScore, num_objectives: int
|
|
76
|
+
) -> list[int | float | str]:
|
|
77
|
+
if num_objectives > 1:
|
|
78
|
+
assert isinstance(
|
|
79
|
+
value, (list, tuple)
|
|
80
|
+
), f"num_objectives > 1 requires list/tuple score; got {type(value).__name__}"
|
|
81
|
+
return cast(list[int | float | str], list(value))
|
|
82
|
+
assert not isinstance(value, (list, tuple))
|
|
83
|
+
return [value]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class Search(BaseModel):
|
|
87
|
+
"""
|
|
88
|
+
Your main class to start a CompileIQ search.
|
|
89
|
+
Instantiate this class with your objective function, search space, and search configuration,
|
|
90
|
+
then call `start()` to run the search and retrieve the results.
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
## User defined (Public) Attributes
|
|
94
|
+
objective_function: Callable = Field(
|
|
95
|
+
description=(
|
|
96
|
+
"A Python function that runs the task and returns score(s). "
|
|
97
|
+
"The function must have all imports and objects declared inside."
|
|
98
|
+
)
|
|
99
|
+
)
|
|
100
|
+
search_space: SearchSpaceInput = Field(
|
|
101
|
+
description=(
|
|
102
|
+
"The user search space for CompileIQ to explore. "
|
|
103
|
+
"The objective function will receive a single set following this declaration. "
|
|
104
|
+
"Accepted values: a dict mapping string keys to compileiq search_spaces functions, "
|
|
105
|
+
"a path to an existing search-space file, or a SearchSpaceProvider instance."
|
|
106
|
+
)
|
|
107
|
+
)
|
|
108
|
+
search_config: SearchConfigInput = Field(
|
|
109
|
+
description=(
|
|
110
|
+
"Search configuration parameters such as generation number and mutation rate. "
|
|
111
|
+
"Accepted values: a SearchConfiguration object, a dict with SearchConfiguration keys, "
|
|
112
|
+
"or a path to an existing .config file."
|
|
113
|
+
)
|
|
114
|
+
)
|
|
115
|
+
worker_type: WorkerTypes | type[Worker] = Field(
|
|
116
|
+
default=WorkerTypes.DEFAULT,
|
|
117
|
+
description=(
|
|
118
|
+
"Selects which worker implementation runs your objective function. "
|
|
119
|
+
"Built-in options via WorkerTypes: NATIVE (default, local multiprocessing), "
|
|
120
|
+
"RAY (distributed via Ray), or ASYNC (asyncio concurrency). "
|
|
121
|
+
"A Worker subclass can also be passed directly for custom implementations."
|
|
122
|
+
),
|
|
123
|
+
)
|
|
124
|
+
tracker_config: TrackerConfig = Field(
|
|
125
|
+
default_factory=DefaultTrackerConfig,
|
|
126
|
+
description=(
|
|
127
|
+
"A TrackerConfig that defines how experiment tracking will be handled. "
|
|
128
|
+
"Refer to TrackerTypes for available options."
|
|
129
|
+
),
|
|
130
|
+
)
|
|
131
|
+
debug: bool = Field(
|
|
132
|
+
default=False,
|
|
133
|
+
description=(
|
|
134
|
+
"When enabled, the cached log from the core subprocess is not deleted, "
|
|
135
|
+
"allowing inspection of its output."
|
|
136
|
+
),
|
|
137
|
+
)
|
|
138
|
+
cache_folder: Optional[pathlib.Path] = Field(
|
|
139
|
+
default=None,
|
|
140
|
+
description=(
|
|
141
|
+
"Base directory for cache files created during the run. "
|
|
142
|
+
"Cleaned up at the end unless `CIQ_KEEP_CACHE=1` is set. "
|
|
143
|
+
"Defaults to `~/.cache/compileiq` if not provided."
|
|
144
|
+
),
|
|
145
|
+
)
|
|
146
|
+
dump_results: Optional[pathlib.Path] = Field(
|
|
147
|
+
default=None,
|
|
148
|
+
description=(
|
|
149
|
+
"If set, the results CSV is written to this path after every evaluation batch "
|
|
150
|
+
"(typically one batch per pool_size evaluations). No file is written if None."
|
|
151
|
+
),
|
|
152
|
+
)
|
|
153
|
+
disable_progress_bar: bool = Field(
|
|
154
|
+
default=False,
|
|
155
|
+
description="Disables the TQDM progress bar.",
|
|
156
|
+
)
|
|
157
|
+
exit_on_failure: bool = Field(
|
|
158
|
+
default=True,
|
|
159
|
+
description=(
|
|
160
|
+
"When True, execution terminates with a RuntimeError if all objectives fail "
|
|
161
|
+
"in the first generation. Set to False if your search has an inherently high "
|
|
162
|
+
"failure rate."
|
|
163
|
+
),
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
## Private Attributes
|
|
167
|
+
_create_new_id: Callable = lambda _: (
|
|
168
|
+
datetime.now().strftime("%Y-%m-%d-%H_%M_%S-") + str(uuid4())
|
|
169
|
+
)
|
|
170
|
+
# CompileIQ Core will give us the id once generation starts
|
|
171
|
+
run_id: int | None = Field(None, init=False)
|
|
172
|
+
_worker: Worker = PrivateAttr()
|
|
173
|
+
_tracker: BaseTracker = PrivateAttr()
|
|
174
|
+
current_generation: int = Field(
|
|
175
|
+
0, init=False, description="Current generation of the search, starting at 0."
|
|
176
|
+
)
|
|
177
|
+
_search_config: InternalSearchConfiguration = PrivateAttr()
|
|
178
|
+
_result: SearchResult = PrivateAttr()
|
|
179
|
+
_using_file_backed_search_space: bool | list[bool] = False
|
|
180
|
+
_multi_config: bool = False
|
|
181
|
+
_search_space_resolution_metadata: list[SearchSpaceResolutionMetadataDict] | None = (
|
|
182
|
+
PrivateAttr(default=None)
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
# Cache directory management
|
|
186
|
+
_base_cache_dir: Optional[pathlib.Path] = PrivateAttr(default=None)
|
|
187
|
+
|
|
188
|
+
# Communication with Core
|
|
189
|
+
_listen_socket: socket.socket = PrivateAttr(default_factory=initialize_socket)
|
|
190
|
+
_core_socket: Optional[socket.socket] = PrivateAttr(default=None)
|
|
191
|
+
_core_ipc: CoreIPC = PrivateAttr()
|
|
192
|
+
|
|
193
|
+
# Pydantic config
|
|
194
|
+
model_config = ConfigDict(extra="forbid", arbitrary_types_allowed=True)
|
|
195
|
+
|
|
196
|
+
@field_validator("search_space", mode="after")
|
|
197
|
+
def validate_windows(cls, value):
|
|
198
|
+
if sys.platform == "win32":
|
|
199
|
+
if isinstance(value, list):
|
|
200
|
+
# If using multiple configs
|
|
201
|
+
raise ValueError("Windows does not support multiple config search spaces")
|
|
202
|
+
|
|
203
|
+
return value
|
|
204
|
+
|
|
205
|
+
@field_validator("search_config", mode="after")
|
|
206
|
+
def normalize_search_config(cls, value: SearchConfigInput) -> SearchConfigInput:
|
|
207
|
+
if isinstance(value, SearchConfiguration):
|
|
208
|
+
return value
|
|
209
|
+
if isinstance(value, dict):
|
|
210
|
+
return SearchConfiguration(**value)
|
|
211
|
+
return SearchConfiguration.from_legacy(str(value))
|
|
212
|
+
|
|
213
|
+
def _do_init_folders(self) -> None:
|
|
214
|
+
# Determine the base cache directory (only on first call)
|
|
215
|
+
if self._base_cache_dir is None:
|
|
216
|
+
if self.cache_folder is None:
|
|
217
|
+
self._base_cache_dir = pathlib.Path(_CACHE_DIR)
|
|
218
|
+
else:
|
|
219
|
+
self._base_cache_dir = pathlib.Path(self.cache_folder)
|
|
220
|
+
|
|
221
|
+
# Always create a flat path: base / new_id (never nest deeper)
|
|
222
|
+
# (this is crucial for Win32 where filepath lengths are bounded)
|
|
223
|
+
self.cache_folder = self._base_cache_dir / str(self._create_new_id())
|
|
224
|
+
self.cache_folder.mkdir(parents=True, exist_ok=True)
|
|
225
|
+
|
|
226
|
+
@model_validator(mode="after")
|
|
227
|
+
def _init_folders(self):
|
|
228
|
+
self._do_init_folders()
|
|
229
|
+
return self
|
|
230
|
+
|
|
231
|
+
def _do_setup_search_config(self) -> None:
|
|
232
|
+
"""
|
|
233
|
+
Converting user-defined configuration to internal representation
|
|
234
|
+
"""
|
|
235
|
+
|
|
236
|
+
search_config = cast(SearchConfiguration, self.search_config)
|
|
237
|
+
self._search_config = InternalSearchConfiguration(**search_config.model_dump())
|
|
238
|
+
|
|
239
|
+
_, search_space_config_filepath = get_core_filepaths(str(self.cache_folder))
|
|
240
|
+
|
|
241
|
+
# Windows workaround with paths
|
|
242
|
+
if sys.platform == "win32":
|
|
243
|
+
search_space_config_filepath = search_space_config_filepath.replace("\\", "\\\\")
|
|
244
|
+
|
|
245
|
+
self._search_config.dna_config = search_space_config_filepath
|
|
246
|
+
|
|
247
|
+
@model_validator(mode="after")
|
|
248
|
+
def _setup_search_config(self):
|
|
249
|
+
self._do_setup_search_config()
|
|
250
|
+
return self
|
|
251
|
+
|
|
252
|
+
def _do_setup(self) -> None:
|
|
253
|
+
"""
|
|
254
|
+
Using this as a secondary __init__ to perform validation and start variables
|
|
255
|
+
that depend on user-defined values, perform additional validation and create
|
|
256
|
+
required files in `cache_folder`.
|
|
257
|
+
"""
|
|
258
|
+
|
|
259
|
+
# Preparing search space - resolve SearchSpaceProvider instances.
|
|
260
|
+
metadata_records: list[SearchSpaceResolutionMetadataDict] = []
|
|
261
|
+
|
|
262
|
+
def resolve_provider(
|
|
263
|
+
search_space: Dict[str, Any] | pathlib.Path | SearchSpaceProvider,
|
|
264
|
+
) -> Dict[str, Any] | pathlib.Path:
|
|
265
|
+
if not isinstance(search_space, SearchSpaceProvider):
|
|
266
|
+
return search_space
|
|
267
|
+
resolved = search_space.retrieve()
|
|
268
|
+
metadata = getattr(search_space, "resolution_metadata", None)
|
|
269
|
+
if metadata is not None:
|
|
270
|
+
metadata_records.append(metadata.as_dict())
|
|
271
|
+
return resolved
|
|
272
|
+
|
|
273
|
+
if isinstance(self.search_space, list):
|
|
274
|
+
# Multi-config searches may mix raw dict/path search spaces with
|
|
275
|
+
# provider-backed entries. Resolve only the provider entries.
|
|
276
|
+
self.search_space = [
|
|
277
|
+
resolve_provider(search_space) for search_space in self.search_space
|
|
278
|
+
]
|
|
279
|
+
else:
|
|
280
|
+
# Common case: a single raw search space or one provider-backed
|
|
281
|
+
# search space that resolves to a local binary path.
|
|
282
|
+
self.search_space = resolve_provider(self.search_space)
|
|
283
|
+
|
|
284
|
+
if metadata_records:
|
|
285
|
+
self._search_space_resolution_metadata = metadata_records
|
|
286
|
+
self._multi_config = isinstance(self.search_space, list)
|
|
287
|
+
if isinstance(self.search_space, list):
|
|
288
|
+
self._using_file_backed_search_space = [
|
|
289
|
+
isinstance(sspace, pathlib.Path) for sspace in self.search_space
|
|
290
|
+
]
|
|
291
|
+
else:
|
|
292
|
+
self._using_file_backed_search_space = isinstance(self.search_space, pathlib.Path)
|
|
293
|
+
|
|
294
|
+
# Initializing Core IPC
|
|
295
|
+
self._core_ipc = CoreIPC()
|
|
296
|
+
|
|
297
|
+
if self.tracker_config.type is None:
|
|
298
|
+
raise ValueError("Tracker is not initialized")
|
|
299
|
+
|
|
300
|
+
if not isinstance(self.tracker_config, TrackerConfig):
|
|
301
|
+
raise ValueError(
|
|
302
|
+
f"Tracker configuration is not a TrackerConfig, got {type(self.tracker_config)}"
|
|
303
|
+
)
|
|
304
|
+
self._tracker = _TRACKER_TYPES_TO_CLASSES[TrackerTypes(self.tracker_config.type)](
|
|
305
|
+
self.tracker_config
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
wt = self.worker_type
|
|
309
|
+
if isinstance(wt, WorkerTypes):
|
|
310
|
+
worker_cls = wt.worker_type()
|
|
311
|
+
elif isinstance(wt, type) and issubclass(wt, Worker):
|
|
312
|
+
worker_cls = wt
|
|
313
|
+
else:
|
|
314
|
+
raise RuntimeError(f"Expected a WorkerTypes or Worker subclass, but found {wt}")
|
|
315
|
+
|
|
316
|
+
assert self.cache_folder is not None, "_init_folders populates cache_folder before _setup"
|
|
317
|
+
self._worker = worker_cls.create(
|
|
318
|
+
cache_folder=self.cache_folder,
|
|
319
|
+
normalize=self._search_config.normalize,
|
|
320
|
+
tracker=self._tracker,
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
@model_validator(mode="after")
|
|
324
|
+
def _setup(self):
|
|
325
|
+
self._do_setup()
|
|
326
|
+
return self
|
|
327
|
+
|
|
328
|
+
def __enter__(self):
|
|
329
|
+
return self
|
|
330
|
+
|
|
331
|
+
def __exit__(self, exc_type, exc_value, exc_traceback):
|
|
332
|
+
del self
|
|
333
|
+
|
|
334
|
+
def __del__(self):
|
|
335
|
+
if hasattr(self, "_listen_socket") and self._listen_socket is not None:
|
|
336
|
+
self._listen_socket.close()
|
|
337
|
+
if hasattr(self, "_worker") and self._worker is not None:
|
|
338
|
+
del self._worker
|
|
339
|
+
if hasattr(self, "_core_ipc") and self._core_ipc is not None:
|
|
340
|
+
del self._core_ipc
|
|
341
|
+
if (
|
|
342
|
+
hasattr(self, "cache_folder")
|
|
343
|
+
and self.cache_folder is not None
|
|
344
|
+
and os.path.exists(self.cache_folder)
|
|
345
|
+
and not KEEP_CACHE_FILES
|
|
346
|
+
):
|
|
347
|
+
self._clean_files()
|
|
348
|
+
|
|
349
|
+
def sample(self, num_samples: int = 1) -> List[ParamArg]:
|
|
350
|
+
"""
|
|
351
|
+
Instead of performing a full search, this function will just sample `num_samples`
|
|
352
|
+
from the search space provided by the user.
|
|
353
|
+
|
|
354
|
+
Args:
|
|
355
|
+
num_samples (`int`): Number of samples to retrieve from the search space.
|
|
356
|
+
Returns:
|
|
357
|
+
A list of parameter sets sampled from the search space, in the same format as the
|
|
358
|
+
parameters sent to the objective function during a normal search.
|
|
359
|
+
"""
|
|
360
|
+
assert self.cache_folder is not None
|
|
361
|
+
main_config_filepath, search_space_config_filepath = get_core_filepaths(self.cache_folder)
|
|
362
|
+
|
|
363
|
+
hijacked_config = self._search_config.model_copy(deep=True)
|
|
364
|
+
hijacked_config.num_objectives = 1
|
|
365
|
+
hijacked_config.pool_size = max(num_samples, 6)
|
|
366
|
+
hijacked_config.cull_size = 2
|
|
367
|
+
try:
|
|
368
|
+
hijacked_config.dna_config = setup_search_space(
|
|
369
|
+
cast(ResolvedSearchSpaceInput, self.search_space), search_space_config_filepath
|
|
370
|
+
)
|
|
371
|
+
setup_legacy_search_config(hijacked_config, main_config_filepath)
|
|
372
|
+
|
|
373
|
+
# Starting Core as a subprocess
|
|
374
|
+
_ = self._core_ipc.start(
|
|
375
|
+
server_socket=self._listen_socket,
|
|
376
|
+
main_config_filepath=main_config_filepath,
|
|
377
|
+
silent=not self.debug,
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
# Wait for core to connect (returns accepted socket and its addr)
|
|
381
|
+
self._core_socket, _ = self._listen_socket.accept()
|
|
382
|
+
|
|
383
|
+
# Executing function throughout the generations
|
|
384
|
+
parameter_sets = self._core_ipc.receive_from_core(self._core_socket)
|
|
385
|
+
if isinstance(parameter_sets, CompletionMessage):
|
|
386
|
+
raise RuntimeError(
|
|
387
|
+
"Something went wrong with the core, enable `debug=True` for debugging."
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
func_args = self._load_params(parameter_sets)
|
|
391
|
+
|
|
392
|
+
finally:
|
|
393
|
+
self._core_ipc.stop()
|
|
394
|
+
if not KEEP_CACHE_FILES:
|
|
395
|
+
if isinstance(hijacked_config.dna_config, list):
|
|
396
|
+
for path in hijacked_config.dna_config:
|
|
397
|
+
os.remove(path)
|
|
398
|
+
else:
|
|
399
|
+
os.remove(hijacked_config.dna_config)
|
|
400
|
+
os.remove(main_config_filepath)
|
|
401
|
+
|
|
402
|
+
return func_args[:num_samples]
|
|
403
|
+
|
|
404
|
+
def start(
|
|
405
|
+
self,
|
|
406
|
+
num_workers: int | None = None,
|
|
407
|
+
task_timeout: int | float | None = None,
|
|
408
|
+
**additional_worker_kwargs,
|
|
409
|
+
) -> SearchResult:
|
|
410
|
+
"""
|
|
411
|
+
The CompileIQ core is started as a subprocess through here.
|
|
412
|
+
|
|
413
|
+
The communication between python process and the subprocess is done through sockets:
|
|
414
|
+
|
|
415
|
+
1. During __init__ we prepare the python socket server for communication with the
|
|
416
|
+
core subprocess.
|
|
417
|
+
2. The `search_space.json` and `main_config.json` files are created inside the cache
|
|
418
|
+
folder. (Core needs these.)
|
|
419
|
+
3. We start the core process with the correct environment variables and wait for
|
|
420
|
+
communication.
|
|
421
|
+
4. The core process will start sending parameter candidates which we will execute
|
|
422
|
+
using multiprocess.
|
|
423
|
+
5. The Python process returns the scores through the socket.
|
|
424
|
+
6. At some point the core process sends a completion flag indicating the end of the
|
|
425
|
+
tune process.
|
|
426
|
+
|
|
427
|
+
Args:
|
|
428
|
+
num_workers:
|
|
429
|
+
The maximum number of processes spawned to run parallel searches.
|
|
430
|
+
This parameter is ignored by workers where `respects_num_workers`
|
|
431
|
+
is `False`.
|
|
432
|
+
|
|
433
|
+
task_timeout:
|
|
434
|
+
The maximum time (in seconds) allowed for a single execution of the objective.
|
|
435
|
+
If the timeout is reached, the worker will treat it as a failed execution and return
|
|
436
|
+
a failed Score. This is useful to prevent workers from hanging indefinitely on
|
|
437
|
+
certain parameter sets.
|
|
438
|
+
|
|
439
|
+
**additional_worker_kwargs:
|
|
440
|
+
Additional keyword arguments forwarded to the worker's ``run()`` method.
|
|
441
|
+
Each worker accepts the kwargs it cares about. For example:
|
|
442
|
+
|
|
443
|
+
- RayWorker: accepts Ray task resource options
|
|
444
|
+
- Custom workers: accept any kwargs they define
|
|
445
|
+
|
|
446
|
+
Returns (`SearchResult`):
|
|
447
|
+
An object with the search results.
|
|
448
|
+
"""
|
|
449
|
+
|
|
450
|
+
if num_workers is not None:
|
|
451
|
+
if not self._worker.respects_num_workers:
|
|
452
|
+
warnings.warn(
|
|
453
|
+
f"num_workers is not supported by {type(self._worker).__name__}", stacklevel=2
|
|
454
|
+
)
|
|
455
|
+
elif num_workers < 1:
|
|
456
|
+
raise ValueError("num_workers must be a positive integer.")
|
|
457
|
+
|
|
458
|
+
if task_timeout is not None and not self._worker.supports_timeout:
|
|
459
|
+
warnings.warn(
|
|
460
|
+
f"task_timeout is not supported by {type(self._worker).__name__}", stacklevel=2
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
worker_count = num_workers if isinstance(num_workers, int) else 1
|
|
464
|
+
|
|
465
|
+
if self.cache_folder is None or not self.cache_folder.exists():
|
|
466
|
+
self._do_init_folders()
|
|
467
|
+
assert self.cache_folder is not None
|
|
468
|
+
|
|
469
|
+
# Initializing Result df
|
|
470
|
+
self._result = SearchResult._initialize_empty(
|
|
471
|
+
num_scores=self._search_config.num_objectives,
|
|
472
|
+
problem_type=self._search_config.problem_type,
|
|
473
|
+
norm_scores=self._search_config.normalize,
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
main_config_filepath, search_space_config_filepath = get_core_filepaths(self.cache_folder)
|
|
477
|
+
try:
|
|
478
|
+
# Configure the core input files.
|
|
479
|
+
self._search_config.dna_config = setup_search_space(
|
|
480
|
+
cast(ResolvedSearchSpaceInput, self.search_space), search_space_config_filepath
|
|
481
|
+
)
|
|
482
|
+
setup_legacy_search_config(self._search_config, main_config_filepath)
|
|
483
|
+
|
|
484
|
+
# Starting Core as a subprocess
|
|
485
|
+
_ = self._core_ipc.start(
|
|
486
|
+
server_socket=self._listen_socket,
|
|
487
|
+
main_config_filepath=main_config_filepath,
|
|
488
|
+
silent=not self.debug,
|
|
489
|
+
)
|
|
490
|
+
|
|
491
|
+
# Wait for core to connect (returns accepted socket and its addr)
|
|
492
|
+
self._core_socket, _ = self._listen_socket.accept()
|
|
493
|
+
|
|
494
|
+
self._tracker.search_starts(
|
|
495
|
+
search_space_resolution_metadata=self._search_space_resolution_metadata
|
|
496
|
+
)
|
|
497
|
+
|
|
498
|
+
# Executing function throughout the generations
|
|
499
|
+
self._result = self._process_candidates(
|
|
500
|
+
worker_count,
|
|
501
|
+
task_timeout=task_timeout,
|
|
502
|
+
**additional_worker_kwargs,
|
|
503
|
+
)
|
|
504
|
+
|
|
505
|
+
finally:
|
|
506
|
+
self._tracker.search_ends()
|
|
507
|
+
self.current_generation = 0
|
|
508
|
+
self._core_ipc.stop()
|
|
509
|
+
if not KEEP_CACHE_FILES:
|
|
510
|
+
self._clean_files()
|
|
511
|
+
|
|
512
|
+
return self._result
|
|
513
|
+
|
|
514
|
+
@property
|
|
515
|
+
def search_space_resolution_metadata(self) -> list[SearchSpaceResolutionMetadataDict] | None:
|
|
516
|
+
"""One metadata record per resolved provider, or None if no provider was used."""
|
|
517
|
+
if self._search_space_resolution_metadata is None:
|
|
518
|
+
return None
|
|
519
|
+
return [dict(metadata) for metadata in self._search_space_resolution_metadata]
|
|
520
|
+
|
|
521
|
+
def _process_candidates(
|
|
522
|
+
self,
|
|
523
|
+
num_workers: int,
|
|
524
|
+
task_timeout: Optional[int | float] = None,
|
|
525
|
+
**additional_worker_kwargs,
|
|
526
|
+
) -> SearchResult:
|
|
527
|
+
"""
|
|
528
|
+
We receive the parameter set, call upon the worker to execute the objective function, and
|
|
529
|
+
send the score back through the socket, until we receive a completion message.
|
|
530
|
+
"""
|
|
531
|
+
pbar = tqdm(
|
|
532
|
+
total=self._search_config.generations,
|
|
533
|
+
ascii="░▒█",
|
|
534
|
+
colour="green",
|
|
535
|
+
disable=self.disable_progress_bar,
|
|
536
|
+
smoothing=0.8,
|
|
537
|
+
bar_format="{desc} {n_fmt}/{total_fmt}|{bar}| [elapsed: {elapsed} · eta: {remaining}]"
|
|
538
|
+
" {postfix}",
|
|
539
|
+
)
|
|
540
|
+
pbar.set_description("🧬 Generation")
|
|
541
|
+
while True:
|
|
542
|
+
try:
|
|
543
|
+
# receiving params ('knobs') from core subprocess
|
|
544
|
+
assert self._core_socket is not None
|
|
545
|
+
parameter_sets = self._core_ipc.receive_from_core(self._core_socket)
|
|
546
|
+
except Exception as e:
|
|
547
|
+
pbar.close()
|
|
548
|
+
raise e
|
|
549
|
+
|
|
550
|
+
# Verify if the message signals the end of the run
|
|
551
|
+
if isinstance(parameter_sets, CompletionMessage):
|
|
552
|
+
if not (parameter_sets.complete):
|
|
553
|
+
raise RuntimeError(
|
|
554
|
+
"Something went wrong with the core, enable `debug=True` for debugging."
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
pbar.update()
|
|
558
|
+
self._result.clear_duplicates()
|
|
559
|
+
return self._result
|
|
560
|
+
|
|
561
|
+
else:
|
|
562
|
+
# Standard flow receiving knobs
|
|
563
|
+
self.run_id = parameter_sets.invocation_id
|
|
564
|
+
|
|
565
|
+
# Updating Progress Bar and Tracker
|
|
566
|
+
if parameter_sets.generation_num != self.current_generation:
|
|
567
|
+
self.current_generation = parameter_sets.generation_num
|
|
568
|
+
self._worker.current_generation = self.current_generation
|
|
569
|
+
|
|
570
|
+
if self._search_config.num_objectives == 1 and self.current_generation > 0:
|
|
571
|
+
# We limit best score display to single objective because multi-objective is
|
|
572
|
+
# a pareto front and it's not straightforward to define a single "best"
|
|
573
|
+
try:
|
|
574
|
+
best_score = self._result.get_best_result()
|
|
575
|
+
except Exception:
|
|
576
|
+
best_score = None
|
|
577
|
+
|
|
578
|
+
if isinstance(best_score, dict):
|
|
579
|
+
gen_from_best = int(best_score["generation"])
|
|
580
|
+
best_score_value = (
|
|
581
|
+
best_score["score_1"]
|
|
582
|
+
if not self._search_config.normalize
|
|
583
|
+
else best_score["norm_score_1"]
|
|
584
|
+
)
|
|
585
|
+
pbar.set_postfix(
|
|
586
|
+
{
|
|
587
|
+
"🏆 best_score": f"{best_score_value:.4f}",
|
|
588
|
+
"at_gen": gen_from_best,
|
|
589
|
+
}
|
|
590
|
+
)
|
|
591
|
+
|
|
592
|
+
pbar.update()
|
|
593
|
+
self._tracker.generation_starts(self.current_generation)
|
|
594
|
+
|
|
595
|
+
elif self.current_generation == 0:
|
|
596
|
+
self._tracker.generation_starts(0)
|
|
597
|
+
|
|
598
|
+
# Processing parameters into dictionaries (if possible)
|
|
599
|
+
func_args = self._load_params(parameter_sets)
|
|
600
|
+
param_ids = [single_param.id for single_param in parameter_sets.params]
|
|
601
|
+
scores = self._worker.run(
|
|
602
|
+
function=self.objective_function,
|
|
603
|
+
tracker=self._tracker,
|
|
604
|
+
params_pool=func_args,
|
|
605
|
+
params_ids=param_ids,
|
|
606
|
+
num_workers=num_workers,
|
|
607
|
+
num_function_returns=self._search_config.num_objectives,
|
|
608
|
+
task_timeout=task_timeout,
|
|
609
|
+
**additional_worker_kwargs,
|
|
610
|
+
)
|
|
611
|
+
|
|
612
|
+
if len(scores) < len(parameter_sets.params):
|
|
613
|
+
raise RuntimeError(
|
|
614
|
+
"The worker returned less scores than the number of parameter sets "
|
|
615
|
+
"requested by the core."
|
|
616
|
+
)
|
|
617
|
+
|
|
618
|
+
if (
|
|
619
|
+
self.exit_on_failure
|
|
620
|
+
and self.current_generation == 0
|
|
621
|
+
and self._check_fail_count(scores)
|
|
622
|
+
):
|
|
623
|
+
raise RuntimeError(
|
|
624
|
+
"All objective functions failed in the first gen. Are you sure this "
|
|
625
|
+
"is expected behavior? You can disable this error by setting"
|
|
626
|
+
"`exit_on_failure=False`."
|
|
627
|
+
)
|
|
628
|
+
|
|
629
|
+
# Storing worker results and preparing response to Core
|
|
630
|
+
scores_response = ResponseTemplate(evaluated_params=[])
|
|
631
|
+
assert isinstance(scores_response.evaluated_params, list)
|
|
632
|
+
num_objectives = self._search_config.num_objectives
|
|
633
|
+
for score in scores:
|
|
634
|
+
self._result.add_result(
|
|
635
|
+
score, parameter_sets.generation_num, self._search_config.normalize
|
|
636
|
+
)
|
|
637
|
+
|
|
638
|
+
# Preparing response to Core
|
|
639
|
+
# Baselines are not reported back to core
|
|
640
|
+
if not score.is_baseline:
|
|
641
|
+
raw_score_value = (
|
|
642
|
+
score.norm_score if self._search_config.normalize else score.score
|
|
643
|
+
)
|
|
644
|
+
assert raw_score_value is not None
|
|
645
|
+
score_value = _expand_objective_values(raw_score_value, num_objectives)
|
|
646
|
+
assert isinstance(score.param_id, int), (
|
|
647
|
+
f"non-baseline score must have int param_id; got "
|
|
648
|
+
f"{type(score.param_id).__name__}"
|
|
649
|
+
)
|
|
650
|
+
|
|
651
|
+
response = EvaluatedParamResponse(id=score.param_id, scores=score_value)
|
|
652
|
+
scores_response.evaluated_params.append(response)
|
|
653
|
+
|
|
654
|
+
# Verifying all scores are returned
|
|
655
|
+
if len(param_ids) != len(scores_response.evaluated_params):
|
|
656
|
+
raise RuntimeError(
|
|
657
|
+
"Worker did not return all scores passed down for calculation."
|
|
658
|
+
f"Sent {len(param_ids)} and only returned "
|
|
659
|
+
f"{len(scores_response.evaluated_params)}"
|
|
660
|
+
)
|
|
661
|
+
|
|
662
|
+
# Checkpointing intermediate results every batch
|
|
663
|
+
if self.dump_results is not None:
|
|
664
|
+
self._result.save(self.dump_results)
|
|
665
|
+
|
|
666
|
+
self._tracker.generation_ends(self.current_generation)
|
|
667
|
+
|
|
668
|
+
# Sending scores to the Core subprocess
|
|
669
|
+
self._core_ipc.send_to_core(
|
|
670
|
+
self._core_socket,
|
|
671
|
+
scores_response,
|
|
672
|
+
)
|
|
673
|
+
|
|
674
|
+
def _load_params(self, parameter_sets: ParameterSet) -> List[ParamArg]:
|
|
675
|
+
"""
|
|
676
|
+
Parse params received from core.
|
|
677
|
+
|
|
678
|
+
File-backed search spaces may return opaque strings for user code to handle.
|
|
679
|
+
Dictionary-defined search spaces must return JSON objects because those are
|
|
680
|
+
restored into the user-facing config dictionary.
|
|
681
|
+
"""
|
|
682
|
+
|
|
683
|
+
def parse_param_payload(param: str, is_file_backed: bool = False):
|
|
684
|
+
try:
|
|
685
|
+
param_set = json.loads(param)
|
|
686
|
+
except (ValueError, json.JSONDecodeError):
|
|
687
|
+
try:
|
|
688
|
+
import json5
|
|
689
|
+
|
|
690
|
+
param_set = json5.loads(param)
|
|
691
|
+
except Exception:
|
|
692
|
+
if not is_file_backed:
|
|
693
|
+
raise RuntimeError(
|
|
694
|
+
"Core returned an unparseable parameter payload for a "
|
|
695
|
+
"dictionary-defined CompileIQ search space. Expected a "
|
|
696
|
+
f"JSON object string, got: {param!r}"
|
|
697
|
+
) from None
|
|
698
|
+
return param
|
|
699
|
+
|
|
700
|
+
if is_file_backed:
|
|
701
|
+
return param_set
|
|
702
|
+
|
|
703
|
+
if not isinstance(param_set, dict):
|
|
704
|
+
raise RuntimeError(
|
|
705
|
+
"Core returned a non-object parameter payload for a dictionary-defined "
|
|
706
|
+
"CompileIQ search space. Expected a JSON object string, got "
|
|
707
|
+
f"{type(param_set).__name__}: {param!r}"
|
|
708
|
+
)
|
|
709
|
+
|
|
710
|
+
return restore_nested_search_space(param_set)
|
|
711
|
+
|
|
712
|
+
params_from_generation: List[ParamArg] = []
|
|
713
|
+
for param in parameter_sets.params:
|
|
714
|
+
if self._multi_config:
|
|
715
|
+
assert isinstance(self._using_file_backed_search_space, list)
|
|
716
|
+
# When specifying multiple configs, a list of base64 strings is returned
|
|
717
|
+
# Each string is the representation from one config (in the same order as
|
|
718
|
+
# provided by the user)
|
|
719
|
+
decoded_param = list(map(_decode_from_core, json.loads(param.knobs)))
|
|
720
|
+
single_pset = [
|
|
721
|
+
parse_param_payload(single_param, self._using_file_backed_search_space[i])
|
|
722
|
+
for i, single_param in enumerate(decoded_param)
|
|
723
|
+
]
|
|
724
|
+
else:
|
|
725
|
+
assert isinstance(self._using_file_backed_search_space, bool)
|
|
726
|
+
single_pset = parse_param_payload(
|
|
727
|
+
param.knobs, self._using_file_backed_search_space
|
|
728
|
+
)
|
|
729
|
+
|
|
730
|
+
params_from_generation.append(cast(ParamArg, single_pset))
|
|
731
|
+
|
|
732
|
+
return params_from_generation
|
|
733
|
+
|
|
734
|
+
def _clean_files(self):
|
|
735
|
+
"""Deletes cache files from this run"""
|
|
736
|
+
if hasattr(self, "_tracker") and self._tracker is not None:
|
|
737
|
+
self._tracker.cleanup()
|
|
738
|
+
if self.cache_folder is not None and pathlib.Path(self.cache_folder).exists():
|
|
739
|
+
shutil.rmtree(self.cache_folder, ignore_errors=True)
|
|
740
|
+
|
|
741
|
+
def _check_fail_count(self, scores: List[Score]):
|
|
742
|
+
"""
|
|
743
|
+
In case all non-baseline scores are failures, this function returns True.
|
|
744
|
+
"""
|
|
745
|
+
return all([score.failed for score in scores if not score.is_baseline])
|