Skip to content

Commit 37bea54

Browse files
authored
Merge pull request #1212 from PyAutoLabs/feature/on-the-fly-modeling
feat: background thread for on-the-fly visualization
2 parents c5eb880 + 1fee931 commit 37bea54

7 files changed

Lines changed: 324 additions & 6 deletions

File tree

autofit/config/general.yaml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
updates:
22
iterations_per_quick_update: 1e99 # Non-linear search iterations between every quick update, which just displays the maximum likelihood model fit.
3-
iterations_per_full_update: 1e99 # Non-linear search iterations between every full update, which outputs all visuals and result fits (e.g. model.result, search.summary), this exits the search and can be slow.
3+
iterations_per_full_update: 1e99 # Non-linear search iterations between every full update, which outputs all visuals and result fits (e.g. model.result, search.summary), this exits the search and can be slow.
4+
quick_update_background: false # If True, quick-update visualization runs on a background thread so sampling is not blocked.
45
hpc:
56
hpc_mode: false # If True, use HPC mode, which disables GUI visualization, logging to screen and other settings which are not suited to running on a super computer.
67
iterations_per_quick_update: 1e99 # Non-linear search iterations between every quick update, which just displays the maximum likelihood model fit.

autofit/non_linear/analysis/analysis.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,16 @@ def make_result(
299299
analysis=analysis,
300300
)
301301

302+
@property
303+
def supports_background_update(self) -> bool:
304+
"""Whether this analysis supports background quick updates."""
305+
return False
306+
307+
@property
308+
def supports_jax_visualization(self) -> bool:
309+
"""Whether the visualizer can work directly with JAX arrays."""
310+
return False
311+
302312
def perform_quick_update(self, paths, instance):
303313
raise NotImplementedError
304314

autofit/non_linear/fitness.py

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ def __init__(
4444
use_jax_vmap : bool = False,
4545
batch_size : Optional[int] = None,
4646
iterations_per_quick_update: Optional[int] = None,
47+
background_quick_update: bool = False,
4748
):
4849
"""
4950
Interfaces with any non-linear search to fit the model to the data and return a log likelihood via
@@ -129,6 +130,20 @@ def __init__(
129130
self.quick_update_max_lh = -self._xp.inf
130131
self.quick_update_count = 0
131132

133+
self._background_quick_update = None
134+
135+
if background_quick_update and self.iterations_per_quick_update is not None:
136+
from autofit.non_linear.quick_update import BackgroundQuickUpdate
137+
138+
convert_jax = (
139+
getattr(self.analysis, "_use_jax", False)
140+
and not getattr(self.analysis, "supports_jax_visualization", False)
141+
)
142+
143+
self._background_quick_update = BackgroundQuickUpdate(
144+
convert_jax=convert_jax,
145+
)
146+
132147
if self.paths is not None:
133148
self.check_log_likelihood(fitness=self)
134149

@@ -314,10 +329,15 @@ def manage_quick_update(self, parameters, log_likelihood):
314329

315330
instance = self.model.instance_from_vector(vector=self.quick_update_max_lh_parameters, xp=self._xp)
316331

317-
try:
318-
self.analysis.perform_quick_update(self.paths, instance)
319-
except NotImplementedError:
320-
pass
332+
if self._background_quick_update is not None:
333+
self._background_quick_update.submit(
334+
self.analysis, self.paths, instance,
335+
)
336+
else:
337+
try:
338+
self.analysis.perform_quick_update(self.paths, instance)
339+
except NotImplementedError:
340+
pass
321341

322342
result_info = text_util.result_max_lh_info_from(
323343
max_log_likelihood_sample=self.quick_update_max_lh_parameters.tolist(),
@@ -333,6 +353,12 @@ def manage_quick_update(self, parameters, log_likelihood):
333353

334354
logger.info(f"Quick update complete in {time.time() - start_time} seconds.")
335355

356+
def shutdown_quick_update(self):
357+
"""Shut down the background quick-update worker, if one is running."""
358+
if self._background_quick_update is not None:
359+
self._background_quick_update.shutdown()
360+
self._background_quick_update = None
361+
336362
@timeout(timeout_seconds)
337363
def __call__(self, parameters, *kwargs):
338364
"""

autofit/non_linear/quick_update.py

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import copy
2+
import logging
3+
import threading
4+
5+
import numpy as np
6+
7+
logger = logging.getLogger(__name__)
8+
9+
10+
def _convert_jax_to_numpy(instance):
11+
"""
12+
Return a deep copy of *instance* with every JAX array replaced by a
13+
NumPy array. Plain NumPy values and non-array attributes are left
14+
unchanged.
15+
16+
This is used so that the background visualisation thread never
17+
touches JAX / GPU state, which is not thread-safe.
18+
"""
19+
instance = copy.deepcopy(instance)
20+
21+
for attr in vars(instance):
22+
value = getattr(instance, attr)
23+
if hasattr(value, "device"):
24+
setattr(instance, attr, np.asarray(value))
25+
26+
return instance
27+
28+
29+
class BackgroundQuickUpdate:
30+
"""
31+
Runs ``analysis.perform_quick_update`` on a background daemon thread so
32+
that the sampler is not blocked while matplotlib renders and saves plots.
33+
34+
Uses a **latest-only** pattern: if a new best-fit arrives before the
35+
previous visualisation finishes, the stale request is silently replaced.
36+
37+
Parameters
38+
----------
39+
convert_jax
40+
If ``True``, JAX arrays on the model instance are converted to
41+
NumPy before handing them to the worker thread.
42+
"""
43+
44+
def __init__(self, convert_jax: bool = False):
45+
self._convert_jax = convert_jax
46+
47+
self._lock = threading.Lock()
48+
self._pending = None
49+
self._has_work = threading.Event()
50+
self._stop = threading.Event()
51+
52+
self._thread = threading.Thread(
53+
target=self._worker,
54+
daemon=True,
55+
name="quick-update-worker",
56+
)
57+
self._thread.start()
58+
59+
def submit(self, analysis, paths, instance):
60+
"""
61+
Enqueue a quick-update request. If a previous request is still
62+
pending (not yet picked up by the worker), it is replaced.
63+
"""
64+
65+
if self._convert_jax:
66+
instance = _convert_jax_to_numpy(instance)
67+
68+
with self._lock:
69+
self._pending = (analysis, paths, instance)
70+
71+
self._has_work.set()
72+
73+
def shutdown(self, timeout: float = 10.0):
74+
"""Signal the worker to stop after draining pending work."""
75+
self._stop.set()
76+
self._has_work.set()
77+
self._thread.join(timeout=timeout)
78+
79+
def _process_pending(self):
80+
with self._lock:
81+
work = self._pending
82+
self._pending = None
83+
84+
if work is None:
85+
return
86+
87+
analysis, paths, instance = work
88+
89+
try:
90+
analysis.perform_quick_update(paths, instance)
91+
except NotImplementedError:
92+
pass
93+
except Exception:
94+
logger.exception(
95+
"Background quick-update raised an exception (ignored)."
96+
)
97+
98+
def _worker(self):
99+
while True:
100+
self._has_work.wait()
101+
self._has_work.clear()
102+
103+
self._process_pending()
104+
105+
if self._stop.is_set():
106+
self._process_pending()
107+
break

autofit/non_linear/search/abstract_search.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,12 @@ def __init__(
214214
self.iterations_per_full_update = float((iterations_per_full_update or
215215
conf.instance["general"]["updates"]["iterations_per_full_update"]))
216216

217+
self.quick_update_background = bool(
218+
conf.instance["general"]["updates"].get(
219+
"quick_update_background", False,
220+
)
221+
)
222+
217223
if conf.instance["general"]["hpc"]["hpc_mode"]:
218224
self.iterations_per_quick_update = float(conf.instance["general"]["hpc"][
219225
"iterations_per_quick_update"
@@ -664,6 +670,9 @@ def start_resume_fit(self, analysis: Analysis, model: AbstractPriorModel) -> Res
664670
analysis=analysis,
665671
)
666672

673+
if hasattr(fitness, "shutdown_quick_update"):
674+
fitness.shutdown_quick_update()
675+
667676
samples = self.perform_update(
668677
model=model,
669678
analysis=analysis,

autofit/non_linear/search/nest/nautilus/search.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,7 @@ def _fit(self, model: AbstractPriorModel, analysis):
192192
fom_is_log_likelihood=True,
193193
resample_figure_of_merit=-1.0e99,
194194
iterations_per_quick_update=self.iterations_per_quick_update,
195+
background_quick_update=self.quick_update_background,
195196
use_jax_vmap=self.use_jax_vmap,
196197
batch_size=self.n_batch,
197198
)
@@ -210,7 +211,8 @@ def _fit(self, model: AbstractPriorModel, analysis):
210211
paths=self.paths,
211212
fom_is_log_likelihood=True,
212213
resample_figure_of_merit=-1.0e99,
213-
iterations_per_quick_update=self.iterations_per_quick_update
214+
iterations_per_quick_update=self.iterations_per_quick_update,
215+
background_quick_update=self.quick_update_background,
214216
)
215217

216218
search_internal = self.fit_multiprocessing(

0 commit comments

Comments
 (0)