1###############################################################################
2# Basic context management with LokyContext
3#
4# author: Thomas Moreau and Olivier Grisel
5#
6# adapted from multiprocessing/context.py
7# * Create a context ensuring loky uses only objects that are compatible
8# * Add LokyContext to the list of context of multiprocessing so loky can be
9# used with multiprocessing.set_start_method
10# * Implement a CFS-aware amd physical-core aware cpu_count function.
11#
12import ctypes
13import math
14import multiprocessing as mp
15import os
16import subprocess
17import sys
18import traceback
19import warnings
20
21from ctypes import wintypes
22from multiprocessing import get_context as mp_get_context
23from multiprocessing.context import BaseContext
24from concurrent.futures.process import _MAX_WINDOWS_WORKERS
25
26
27from .process import LokyProcess, LokyInitMainProcess
28
29# Apparently, on older Python versions, loky cannot work 61 workers on Windows
30# but instead 60: ¯\_(ツ)_/¯
31if sys.version_info < (3, 10):
32 _MAX_WINDOWS_WORKERS = _MAX_WINDOWS_WORKERS - 1
33
34START_METHODS = ["loky", "loky_init_main", "spawn"]
35if sys.platform != "win32":
36 START_METHODS += ["fork", "forkserver"]
37
38_DEFAULT_START_METHOD = None
39
40# Cache for the number of physical cores, to avoid repeating subprocess
41# calls or re-parsing /proc/cpuinfo. Keyed by None for the whole machine, or
42# by a frozenset of logical CPU ids when restricted to a CPU affinity mask,
43# see `_count_physical_cores`.
44physical_cores_cache = {}
45
46
47def get_context(method=None):
48 # Try to overload the default context
49 method = method or _DEFAULT_START_METHOD or "loky"
50 if method == "fork":
51 # If 'fork' is explicitly requested, warn user about potential issues.
52 warnings.warn(
53 "`fork` start method should not be used with "
54 "`loky` as it does not respect POSIX. Try using "
55 "`spawn` or `loky` instead.",
56 UserWarning,
57 )
58 try:
59 return mp_get_context(method)
60 except ValueError:
61 raise ValueError(
62 f"Unknown context '{method}'. Value should be in "
63 f"{START_METHODS}."
64 )
65
66
67def set_start_method(method, force=False):
68 global _DEFAULT_START_METHOD
69 if _DEFAULT_START_METHOD is not None and not force:
70 raise RuntimeError("context has already been set")
71 assert method is None or method in START_METHODS, (
72 f"'{method}' is not a valid start_method. It should be in "
73 f"{START_METHODS}"
74 )
75
76 _DEFAULT_START_METHOD = method
77
78
79def get_start_method():
80 return _DEFAULT_START_METHOD
81
82
83def cpu_count(only_physical_cores=False):
84 """Return the number of CPUs the current process can use.
85
86 The returned number of CPUs accounts for:
87 * the number of CPUs in the system, as given by
88 ``multiprocessing.cpu_count``;
89 * the CPU affinity settings of the current process
90 (available on some Unix systems);
91 * Cgroup CPU bandwidth limit (available on Linux only, typically
92 set by docker and similar container orchestration systems);
93 * the value of the LOKY_MAX_CPU_COUNT environment variable if defined.
94 and is given as the minimum of these constraints.
95
96 If ``only_physical_cores`` is True, return the number of physical cores
97 instead of the number of logical cores (hyperthreading / SMT): this is
98 given as the minimum of the same constraints as above, together with the
99 number of physical cores.
100 On Linux, the computed number of physical cores is restrained to the
101 physical cores reachable through the CPU affinity mask.
102 On other platforms, it's the number of physical cores on the whole
103 machine, since the affinity mask cannot be taken into account there.
104
105 Note that on Windows, the returned number of CPUs cannot exceed 61, see:
106 https://bugs.python.org/issue26903.
107
108 It is also always larger or equal to 1.
109 """
110 # Note: os.cpu_count() is allowed to return None in its docstring
111 os_cpu_count = os.cpu_count() or 1
112 if sys.platform == "win32":
113 # On Windows, attempting to use more than 61 CPUs would result in a
114 # OS-level error. See https://bugs.python.org/issue26903. According to
115 # https://learn.microsoft.com/en-us/windows/win32/procthread/processor-groups
116 # it might be possible to go beyond with a lot of extra work but this
117 # does not look easy.
118 os_cpu_count = min(os_cpu_count, _MAX_WINDOWS_WORKERS)
119
120 cpu_affinity_set = _cpu_count_affinity_set()
121 cpu_count_affinity = (
122 os_cpu_count if cpu_affinity_set is None else len(cpu_affinity_set)
123 )
124 cpu_count_cgroup = _cpu_count_cgroup(os_cpu_count)
125 cpu_count_loky = int(os.environ.get("LOKY_MAX_CPU_COUNT", os_cpu_count))
126
127 if not only_physical_cores:
128 return max(
129 1,
130 min(
131 os_cpu_count,
132 cpu_count_affinity,
133 cpu_count_cgroup,
134 cpu_count_loky,
135 ),
136 )
137
138 cpu_count_physical, exception = _count_physical_cores(
139 # On Linux, try to collapse SMT/hyper-threading sibling logical CPUs
140 # reachable through the current CPU affinity mask that share the same
141 # physical core. See https://github.com/joblib/loky/issues/639.
142 # On other platforms we lack easy access to CPU topology info to refine
143 # the count, so just pass None for `cpu_affinity_set`.
144 cpu_affinity_set
145 if sys.platform == "linux"
146 else None
147 )
148 if cpu_count_physical == "not found":
149 cpu_count_physical = os_cpu_count
150
151 _warn_physical_cores_not_found(exception)
152
153 return max(
154 1,
155 min(
156 os_cpu_count,
157 cpu_count_physical,
158 cpu_count_affinity,
159 cpu_count_cgroup,
160 cpu_count_loky,
161 ),
162 )
163
164
165def _warn_physical_cores_not_found(exception):
166 if exception is not None:
167 # warns only the first time
168 warnings.warn(
169 "Could not find the number of physical cores for the "
170 f"following reason:\n{exception}\n"
171 "Returning the number of logical cores instead. You can "
172 "silence this warning by setting LOKY_MAX_CPU_COUNT to "
173 "the number of cores you want to use."
174 )
175 traceback.print_tb(exception.__traceback__)
176
177
178def _cpu_count_cgroup(os_cpu_count):
179 # Cgroup CPU bandwidth limit available in Linux since 2.6 kernel
180 cpu_max_fname = "/sys/fs/cgroup/cpu.max"
181 cfs_quota_fname = "/sys/fs/cgroup/cpu/cpu.cfs_quota_us"
182 cfs_period_fname = "/sys/fs/cgroup/cpu/cpu.cfs_period_us"
183
184 cpu_quota_us = None
185 cpu_period_us = None
186
187 if os.path.exists(cpu_max_fname):
188 # cgroup v2
189 # https://www.kernel.org/doc/html/latest/admin-guide/cgroup-v2.html
190 with open(cpu_max_fname) as fh:
191 # Parse the quota and period values
192 parts = fh.read().strip().split()
193 if len(parts) == 2:
194 cpu_quota_us, cpu_period_us = parts
195 # If len(parts) != 2, leave as None and fall back to v1
196
197 # If we didn't get values from cgroup v2, try cgroup v1
198 if cpu_quota_us is None or cpu_period_us is None:
199 if os.path.exists(cfs_quota_fname) and os.path.exists(
200 cfs_period_fname
201 ):
202 # cgroup v1
203 # https://www.kernel.org/doc/html/latest/scheduler/sched-bwc.html#management
204 with open(cfs_quota_fname) as fh:
205 cpu_quota_us = fh.read().strip()
206 with open(cfs_period_fname) as fh:
207 cpu_period_us = fh.read().strip()
208 else:
209 # No Cgroup CPU bandwidth limit (e.g. non-Linux platform)
210 cpu_quota_us = "max"
211
212 if cpu_quota_us == "max":
213 # No active Cgroup quota on a Cgroup-capable platform
214 return os_cpu_count
215 else:
216 cpu_quota_us = int(cpu_quota_us)
217 cpu_period_us = int(cpu_period_us)
218 if cpu_quota_us > 0 and cpu_period_us > 0:
219 return math.ceil(cpu_quota_us / cpu_period_us)
220 else: # pragma: no cover
221 # Setting a negative cpu_quota_us value is a valid way to disable
222 # cgroup CPU bandwidth limits
223 return os_cpu_count
224
225
226def _cpu_count_affinity_set():
227 """Return the current CPU affinity mask as a set of logical CPU ids.
228
229 Return None if the affinity mask cannot be determined on this platform,
230 for instance because neither `os.sched_getaffinity` nor `psutil` are
231 available.
232 """
233 if hasattr(os, "sched_getaffinity"):
234 try:
235 return os.sched_getaffinity(0)
236 except NotImplementedError:
237 pass
238
239 # On some platforms, os.sched_getaffinity does not exist or raises
240 # NotImplementedError, let's try with the psutil if installed.
241 try:
242 import psutil
243
244 p = psutil.Process()
245 if hasattr(p, "cpu_affinity"):
246 return set(p.cpu_affinity())
247
248 except ImportError: # pragma: no cover
249 if (
250 sys.platform == "linux"
251 and os.environ.get("LOKY_MAX_CPU_COUNT") is None
252 ):
253 # Some platforms don't implement os.sched_getaffinity on Linux which
254 # can cause severe oversubscription problems. Better warn the
255 # user in this particularly pathological case which can wreck
256 # havoc, typically on CI workers.
257 warnings.warn(
258 "Failed to inspect CPU affinity constraints on this system. "
259 "Please install psutil or explictly set LOKY_MAX_CPU_COUNT."
260 )
261
262 return None
263
264
265def _count_physical_cores(cpu_set=None):
266 """Return a tuple (number of physical cores, exception)
267
268 If the number of physical cores is found, exception is set to None.
269 If it has not been found, return ("not found", exception).
270
271 If `cpu_set` is not None, only the logical CPUs it contains are
272 considered when counting physical cores, which also collapses
273 SMT/hyper-threading sibling logical CPUs that share the same physical
274 core. Only implemented for Linux, where per-CPU topology information is
275 readily available; callers are expected to only pass a non-None
276 `cpu_set` on Linux.
277
278 The number of physical cores is cached (per distinct `cpu_set` when one
279 is passed) to avoid repeating subprocess calls or re-parsing
280 /proc/cpuinfo.
281 """
282 exception = None
283
284 # First check if the value is cached
285 global physical_cores_cache
286 cache_key = None if cpu_set is None else frozenset(cpu_set)
287 if cache_key in physical_cores_cache:
288 return physical_cores_cache[cache_key], None
289
290 # Not cached yet, find it
291 try:
292 if sys.platform == "linux":
293 cpu_count_physical = _count_physical_cores_linux(cpu_set)
294 elif cpu_set is not None:
295 raise NotImplementedError(
296 "affinity-aware physical core counting is only "
297 "implemented on Linux"
298 )
299 elif sys.platform == "win32":
300 cpu_count_physical = _count_physical_cores_win32()
301 elif sys.platform == "darwin":
302 cpu_count_physical = _count_physical_cores_darwin()
303 elif sys.platform.startswith("freebsd"):
304 cpu_count_physical = _count_physical_cores_freebsd()
305 else:
306 raise NotImplementedError(f"unsupported platform: {sys.platform}")
307
308 # if cpu_count_physical < 1, we did not find a valid value
309 if cpu_count_physical < 1:
310 raise ValueError(f"found {cpu_count_physical} physical cores < 1")
311
312 except Exception as e:
313 exception = e
314 cpu_count_physical = "not found"
315
316 # Put the result in cache
317 physical_cores_cache[cache_key] = cpu_count_physical
318
319 return cpu_count_physical, exception
320
321
322def _count_physical_cores_linux(cpu_set=None):
323 """Return the number of distinct physical cores on Linux, by parsing
324 /proc/cpuinfo.
325
326 A physical core is identified by its (physical id, core id) pair: core
327 id alone is only guaranteed unique within a physical package, so a
328 multi-socket machine can otherwise under-count cores that share the
329 same core id across sockets.
330
331 If `cpu_set` is not None, only the logical CPUs it contains are
332 considered, which also collapses SMT/hyper-threading sibling logical
333 CPUs that share the same physical core.
334 """
335 with open("/proc/cpuinfo") as f:
336 cpuinfo = f.read()
337
338 cores = set()
339 processor = core_id = physical_id = None
340 for line in cpuinfo.splitlines() + [""]:
341 if not line.strip():
342 # blank line: end of the current logical CPU block
343 if core_id is not None and (
344 cpu_set is None or processor in cpu_set
345 ):
346 cores.add((physical_id, core_id))
347 processor = core_id = physical_id = None
348 continue
349
350 key, sep, value = line.partition(":")
351 if not sep:
352 continue
353 key = key.strip()
354 value = value.strip()
355 if key == "processor":
356 processor = int(value)
357 elif key == "core id":
358 core_id = value
359 elif key == "physical id":
360 physical_id = value
361
362 if not cores:
363 raise ValueError("could not find any physical core in /proc/cpuinfo")
364
365 return len(cores)
366
367
368def _count_physical_cores_win32():
369 try:
370 return _count_physical_cores_win32_ctypes()
371 except Exception:
372 pass # fallback to powershell
373 try:
374 return _count_physical_cores_win32_powershell()
375 except Exception:
376 pass # fallback to wmic (older Windows versions; deprecated now)
377
378 cpu_info = subprocess.run(
379 "wmic CPU Get NumberOfCores /Format:csv".split(),
380 capture_output=True,
381 text=True,
382 creationflags=subprocess.CREATE_NO_WINDOW,
383 )
384 cpu_info = cpu_info.stdout.splitlines()
385 cpu_info = [
386 l.split(",")[1] for l in cpu_info if (l and l != "Node,NumberOfCores")
387 ]
388 return sum(map(int, cpu_info))
389
390
391def _count_physical_cores_win32_powershell():
392 cmd = "-NoProfile -Command (Get-CimInstance -ClassName Win32_Processor).NumberOfCores"
393 cpu_info = subprocess.run(
394 f"powershell.exe {cmd}".split(),
395 capture_output=True,
396 text=True,
397 creationflags=subprocess.CREATE_NO_WINDOW,
398 )
399 cpu_info = cpu_info.stdout.splitlines()
400 return sum(map(int, cpu_info))
401
402
403def _count_physical_cores_win32_ctypes():
404 ERROR_INSUFFICIENT_BUFFER = 122
405 RelationProcessorCore = 0
406
407 kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
408 get_logical_processor_information = (
409 kernel32.GetLogicalProcessorInformationEx
410 )
411 get_logical_processor_information.argtypes = [
412 wintypes.DWORD,
413 ctypes.c_void_p,
414 ctypes.POINTER(wintypes.DWORD),
415 ]
416 get_logical_processor_information.restype = wintypes.BOOL
417
418 # Mirror the header of SYSTEM_LOGICAL_PROCESSOR_INFORMATION_EX. The full
419 # structure is variable-sized, and only Relationship and Size are needed.
420 # https://learn.microsoft.com/en-us/windows/win32/api/winnt/ns-winnt-system_logical_processor_information_ex
421 class SYSTEM_LOGICAL_PROCESSOR_INFORMATION_EX(ctypes.Structure):
422 _fields_ = [
423 ("Relationship", wintypes.DWORD),
424 ("Size", wintypes.DWORD),
425 ]
426
427 # First obtain the required buffer size. This call is expected to fail with
428 # ERROR_INSUFFICIENT_BUFFER and set returned_length.
429 # https://learn.microsoft.com/en-us/windows/win32/api/sysinfoapi/nf-sysinfoapi-getlogicalprocessorinformationex
430 returned_length = wintypes.DWORD()
431 returned_length_ref = ctypes.byref(returned_length)
432 if get_logical_processor_information(
433 RelationProcessorCore, None, returned_length_ref
434 ):
435 raise RuntimeError("unexpected successful buffer sizing call")
436
437 error = ctypes.get_last_error()
438 if error != ERROR_INSUFFICIENT_BUFFER:
439 raise ctypes.WinError(error)
440
441 buf = ctypes.create_string_buffer(returned_length.value)
442 if not get_logical_processor_information(
443 RelationProcessorCore, buf, returned_length_ref
444 ):
445 raise ctypes.WinError(ctypes.get_last_error())
446
447 offset = 0
448 physical_core_count = 0
449 header_size = ctypes.sizeof(SYSTEM_LOGICAL_PROCESSOR_INFORMATION_EX)
450
451 while offset < returned_length.value:
452 remaining = returned_length.value - offset
453 if remaining < header_size:
454 raise RuntimeError("truncated processor information record")
455
456 processor_core_info = (
457 SYSTEM_LOGICAL_PROCESSOR_INFORMATION_EX.from_buffer(buf, offset)
458 )
459 record_size = processor_core_info.Size
460 if record_size < header_size or record_size > remaining:
461 raise RuntimeError("invalid processor information record size")
462 if processor_core_info.Relationship != RelationProcessorCore:
463 raise RuntimeError("unexpected logical processor relationship")
464
465 physical_core_count += 1
466 offset += record_size
467
468 if physical_core_count == 0:
469 raise RuntimeError("Windows reported no active physical cores")
470
471 if physical_core_count < 1:
472 raise RuntimeError(
473 "GetLogicalProcessorInformationEx returned no physical cores"
474 )
475 return physical_core_count
476
477
478def _count_physical_cores_darwin():
479 cpu_info = subprocess.run(
480 "sysctl -n hw.physicalcpu".split(),
481 capture_output=True,
482 text=True,
483 )
484 cpu_info = cpu_info.stdout
485 return int(cpu_info)
486
487
488def _count_physical_cores_freebsd():
489 cpu_info = subprocess.run(
490 "sysctl -n kern.smp.cores".split(),
491 capture_output=True,
492 text=True,
493 )
494 cpu_info = cpu_info.stdout
495 return int(cpu_info)
496
497
498class LokyContext(BaseContext):
499 """Context relying on the LokyProcess."""
500
501 _name = "loky"
502 Process = LokyProcess
503 cpu_count = staticmethod(cpu_count)
504
505 def Queue(self, maxsize=0, reducers=None):
506 """Returns a queue object"""
507 from .queues import Queue
508
509 return Queue(maxsize, reducers=reducers, ctx=self.get_context())
510
511 def SimpleQueue(self, reducers=None):
512 """Returns a queue object"""
513 from .queues import SimpleQueue
514
515 return SimpleQueue(reducers=reducers, ctx=self.get_context())
516
517 if sys.platform != "win32":
518 """For Unix platform, use our custom implementation of synchronize
519 ensuring that we use the loky.backend.resource_tracker to clean-up
520 the semaphores in case of a worker crash.
521 """
522
523 def Semaphore(self, value=1):
524 """Returns a semaphore object"""
525 from .synchronize import Semaphore
526
527 return Semaphore(value=value)
528
529 def BoundedSemaphore(self, value):
530 """Returns a bounded semaphore object"""
531 from .synchronize import BoundedSemaphore
532
533 return BoundedSemaphore(value)
534
535 def Lock(self):
536 """Returns a lock object"""
537 from .synchronize import Lock
538
539 return Lock()
540
541 def RLock(self):
542 """Returns a recurrent lock object"""
543 from .synchronize import RLock
544
545 return RLock()
546
547 def Condition(self, lock=None):
548 """Returns a condition object"""
549 from .synchronize import Condition
550
551 return Condition(lock)
552
553 def Event(self):
554 """Returns an event object"""
555 from .synchronize import Event
556
557 return Event()
558
559
560class LokyInitMainContext(LokyContext):
561 """Extra context with LokyProcess, which does load the main module
562
563 This context is used for compatibility in the case ``cloudpickle`` is not
564 present on the running system. This permits to load functions defined in
565 the ``main`` module, using proper safeguards. The declaration of the
566 ``executor`` should be protected by ``if __name__ == "__main__":`` and the
567 functions and variable used from main should be out of this block.
568
569 This mimics the default behavior of multiprocessing under Windows and the
570 behavior of the ``spawn`` start method on a posix system.
571 For more details, see the end of the following section of python doc
572 https://docs.python.org/3/library/multiprocessing.html#multiprocessing-programming
573 """
574
575 _name = "loky_init_main"
576 Process = LokyInitMainProcess
577
578
579# Register loky context so it works with multiprocessing.get_context
580ctx_loky = LokyContext()
581mp.context._concrete_contexts["loky"] = ctx_loky
582mp.context._concrete_contexts["loky_init_main"] = LokyInitMainContext()