Skip to content

Commit 3086a75

Browse files
authored
FIX Don't send ScoringMonitor._log to the pickler (scikit-learn#34821)
1 parent ae50210 commit 3086a75

3 files changed

Lines changed: 88 additions & 0 deletions

File tree

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
- Fixed a bug where fitting an estimator with a :class:`callback.ScoringMonitor` and a
2+
parallel backend using multi-processing could fail with "Could not pickle the task to
3+
send it to the workers".
4+
By :user:`Jérémie du Boisberranger <jeremiedbb>`.

sklearn/callback/_scoring_monitor.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,12 @@ def on_fit_task_end(
170170

171171
send(self._listener_handle, (run_id, run_info, task_info_path, scores))
172172

173+
def __getstate__(self):
174+
# `_log` is grown by the listener thread, which can run while this callback is
175+
# being pickled by another thread, e.g. loky's queue feeder when a task is
176+
# dispatched to a worker. The pickler must therefore never walk the live list.
177+
return {**self.__dict__, "_log": list(self._log)}
178+
173179
def __setstate__(self, state):
174180
"""Restore state, opening a fresh listener if the inherited one is unusable."""
175181
self.__dict__.update(state)

sklearn/callback/tests/test_pickle.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
unpickled in a fresh Python interpreter.
99
"""
1010

11+
import functools
12+
import io
1113
import pickle
1214
import re
1315
import subprocess
@@ -17,6 +19,7 @@
1719
import pytest
1820

1921
from sklearn.callback import ProgressBar, ScoringMonitor
22+
from sklearn.callback._transport import _message_consumers
2023
from sklearn.callback.tests._common.estimators import MaxIterEstimator
2124
from sklearn.datasets import make_regression
2225

@@ -139,3 +142,78 @@ def test_callbacks_refit_after_load_in_fresh_process(tmp_path, capsys):
139142
stdout = result.stdout.decode()
140143
assert re.search(r"MaxIterEstimator - fit", stdout)
141144
assert re.search(r"100%", stdout)
145+
146+
147+
class _TraversalRecorder(pickle.Pickler):
148+
"""A pickler that records the id of every object it walks through.
149+
150+
`persistent_id` is called for every object the pickler encounters, so an object is
151+
recorded whichever path leads to it, not only when it is a direct attribute of the
152+
object being pickled,
153+
see https://docs.python.org/3/library/pickle.html#pickle.Pickler.persistent_id.
154+
"""
155+
156+
def __init__(self):
157+
super().__init__(io.BytesIO(), protocol=pickle.HIGHEST_PROTOCOL)
158+
self.walked_through = set()
159+
160+
def persistent_id(self, obj):
161+
self.walked_through.add(id(obj))
162+
return None # pickle `obj` as usual
163+
164+
165+
def _checked(hook):
166+
"""Wrap a callback hook so that it first checks the callback it is called on.
167+
168+
The check is that the pickler does not walk through the objects that listener
169+
threads mutate. They are found through the registered consumers: a consumer is
170+
normally a method bound to the container it fills, e.g. `self._log.append` for
171+
ScoringMonitor or `queue.put` for ProgressBar, so the container is what it is bound
172+
to. A consumer bound to nothing, e.g. a closure, is skipped, since there is then no
173+
way to tell what it mutates.
174+
"""
175+
176+
# preserve the signature of the hook because callbacks are validated against it
177+
@functools.wraps(hook)
178+
def checked_hook(self, *args, **kwargs):
179+
# snapshot because another thread may register a listener concurrently
180+
consumers = list(_message_consumers.values())
181+
# the containers are held, not just their ids, which could be reused once freed
182+
watched = [c.__self__ for c in consumers if hasattr(c, "__self__")]
183+
184+
recorder = _TraversalRecorder()
185+
recorder.dump(self)
186+
187+
offenders = recorder.walked_through & {id(container) for container in watched}
188+
assert not offenders, (
189+
f"Pickling {self.__class__.__name__} walks through a container that a"
190+
" listener thread mutates concurrently. Keep it away from the pickler,"
191+
" either by handing over a copy of it in the callback's `__getstate__`, or"
192+
" by storing it outside of the callback instance."
193+
)
194+
195+
return hook(self, *args, **kwargs)
196+
197+
return checked_hook
198+
199+
200+
@pytest.mark.parametrize("factory", CALLBACK_FACTORIES)
201+
def test_listener_state_is_not_walked_by_the_pickler(factory, monkeypatch):
202+
"""Check that pickling a callback never traverses state its listener mutates.
203+
204+
An estimator carrying a callback can be pickled by a background thread, e.g. loky's
205+
queue feeder dispatching a task to a worker, while the listener thread of that same
206+
callback mutates the callback's state as messages come in. Pickling a container that
207+
another thread mutates breaks the dump, which joblib reports as "Could not pickle
208+
the task to send it to the workers".
209+
210+
The check runs from a hook, i.e. while the listeners are up, which is when such a
211+
dispatch would happen.
212+
"""
213+
callback = factory()
214+
215+
for hook_name in ("on_fit_task_begin", "on_fit_task_end"):
216+
hook = getattr(callback.__class__, hook_name)
217+
monkeypatch.setattr(callback.__class__, hook_name, _checked(hook))
218+
219+
MaxIterEstimator(max_iter=3).set_callbacks(callback).fit()

0 commit comments

Comments
 (0)