Skip to content

Commit 8f2698f

Browse files
authored
Merge pull request #414 from KumarLabJax/fix/klaus-525-macos-forkserver-workers
Use forkserver on macOS to fix worker-pool crash (KLAUS-525)
2 parents fb6d8df + 0258f2f commit 8f2698f

2 files changed

Lines changed: 103 additions & 77 deletions

File tree

src/jabs/project/parallel_workers.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,21 @@
3232
from jabs.pose_estimation import PoseEstimation
3333

3434

35+
def preload_worker_modules() -> None:
36+
"""Process-pool initializer that warms each worker's import cache.
37+
38+
Intended to be passed as the ``initializer`` for the application
39+
``ProcessPoolManager``. A worker must import this module to resolve this
40+
reference, and importing it (with its transitive dependencies) pulls in the
41+
heavy stack workers need: numpy, pandas and h5py directly, plus
42+
``jabs.feature_extraction`` (which brings in scipy) and the pose/video
43+
readers (which bring in OpenCV). Doing that once at pool warm-up means the
44+
first project-load scan and the first training run don't pay the import
45+
cost. The function body is intentionally empty; the import triggered by
46+
resolving it is the whole point.
47+
"""
48+
49+
3550
class _BaseFeatureLoadJobSpec(TypedDict):
3651
"""Shared fields for binary and multi-class feature-load jobs."""
3752

src/jabs/scripts/gui_entrypoint.py

Lines changed: 88 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -1,97 +1,110 @@
1+
"""GUI entry point for JABS.
2+
3+
All non-stdlib imports are deferred into :func:`main` rather than performed at
4+
module scope. On macOS the process pool uses the ``forkserver`` start method (see
5+
:func:`_select_start_method`): its server process imports this module to locate
6+
worker functions but never runs :func:`main`, so keeping the imports out of module
7+
scope keeps that server free of Qt/Foundation. Otherwise the workers it forks
8+
would abort via the Objective-C fork-safety guard (surfacing as
9+
``BrokenProcessPool``) on their first Accelerate or Qt call.
10+
"""
11+
112
import argparse
213
import contextlib
314
import logging
415
import multiprocessing
516
import os
617
import sys
718

8-
# PERFORMANCE FIX: Use fork instead of spawn for faster process creation on macOS
9-
#
10-
# Background: Initializing JABS-AppProcessPool on macOS was taking significant time, which
11-
# got significantly worse (25s) with macOS Tahoe (maybe Sequoia+ ?)
12-
# Potential cause: macOS Sequoia+ scans adhoc-signed executables on every spawn,
13-
# causing significant overhead per worker process. Using fork() avoids this entirely.
14-
#
15-
# Why it's faster:
16-
# - Workers inherit parent's memory (no re-importing modules)
17-
# - No new executable spawned (no macOS security scans)
18-
#
19-
# Safety considerations:
20-
# - fork() is generally unsafe with multi-threaded programs
21-
# - Qt uses threads internally, so there's some risk
22-
# - We mitigate this by:
23-
# 1. Initialize pool BEFORE Qt initializes (forked from single-threaded state)
24-
# 2. Workers only read files and do data processing (no Qt usage)
25-
# 3. Extensive testing shows stability in practice
26-
#
27-
# TODO: Test Windows to see if there is benefit to using "fork" there as well.
28-
if sys.platform == "darwin":
29-
# try to use 'fork' start method on macOS, suppress RuntimeError if it fails -- we'll fall back to default
30-
with contextlib.suppress(RuntimeError):
31-
multiprocessing.set_start_method("fork", force=True)
32-
33-
# suppress some potential harmless warnings from Chromium when user opens UserGuideDialog on some platforms
34-
# we need to set these before importing PySide6.QtWebEngine, so we do it before all PySide6 and JABS imports
35-
os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = (
36-
"--disable-skia-graphite --disable-logging --log-level=3"
37-
)
38-
os.environ["QT_LOGGING_RULES"] = "qt.webenginecontext=false"
39-
from PySide6 import QtWidgets
40-
from PySide6.QtGui import QIcon
41-
42-
from jabs.core.constants import APP_NAME, APP_NAME_LONG, ORG_NAME
43-
from jabs.core.utils.process_pool_manager import ProcessPoolManager
44-
from jabs.resources import ICON_PATH
45-
from jabs.ui import MainWindow
46-
from jabs.version import version_str
47-
48-
# Set log level from environment variable if present
49-
log_level_str = os.environ.get("JABS_LOG_LEVEL", "WARNING").upper()
50-
try:
51-
log_level = getattr(logging, log_level_str)
52-
except AttributeError:
53-
log_level = logging.WARNING
54-
logger = logging.getLogger("jabs.gui_entrypoint")
55-
logger.warning(f"Invalid JABS_LOG_LEVEL '{log_level_str}', defaulting to WARNING.")
56-
logging.basicConfig(level=log_level)
5719
logger = logging.getLogger("jabs.gui_entrypoint")
5820

5921

60-
# logger wasn't setup when we set the multiprocessing start method
61-
# if we need to log anything related to that, do it here
62-
if sys.platform == "darwin" and multiprocessing.get_start_method() != "fork":
63-
logger.warning(
64-
"Failed to set multiprocessing start method to 'fork' on macOS, "
65-
"this may lead to slower process pool initialization."
66-
)
22+
def _configure_logging() -> None:
23+
"""Configure root logging from the ``JABS_LOG_LEVEL`` env var (default WARNING)."""
24+
log_level_str = os.environ.get("JABS_LOG_LEVEL", "WARNING").upper()
25+
log_level = getattr(logging, log_level_str, None)
26+
if not isinstance(log_level, int):
27+
logging.basicConfig(level=logging.WARNING)
28+
logger.warning("Invalid JABS_LOG_LEVEL '%s', defaulting to WARNING.", log_level_str)
29+
return
30+
logging.basicConfig(level=log_level)
6731

6832

69-
def main():
70-
"""main entrypoint for JABS video labeling and classifier GUI
33+
def _select_start_method() -> None:
34+
"""Select the multiprocessing start method (macOS only).
7135
72-
takes one optional positional argument: path to project directory
36+
macOS: use ``forkserver``. ``fork`` is unsafe here -- forked workers abort
37+
via the Objective-C fork-safety guard when they call into Apple Accelerate
38+
(numpy/scipy) or Qt/Foundation, surfacing as ``BrokenProcessPool`` during
39+
feature generation. ``spawn`` is safe but cold-starts a fresh interpreter
40+
per worker (~15-20s on first project load). ``forkserver`` forks workers
41+
from a single pre-warmed, Qt/Accelerate-free server: fast like ``fork``
42+
and safe like ``spawn``. See KLAUS-525.
43+
44+
Other platforms keep their default (Linux fork/forkserver, Windows spawn).
45+
46+
Configure logging before calling this: it warns (rather than failing) if the
47+
method could not be set, so the degraded state is at least visible.
7348
"""
49+
if sys.platform != "darwin":
50+
return
51+
with contextlib.suppress(RuntimeError):
52+
multiprocessing.set_start_method("forkserver", force=True)
53+
method = multiprocessing.get_start_method()
54+
if method != "forkserver":
55+
# The macOS default is 'spawn' (safe but slow); 'fork' would be unstable.
56+
# Warn rather than abort -- spawn still works, just with a slow first load.
57+
logger.warning(
58+
"Could not set multiprocessing start method to 'forkserver' (got '%s'); "
59+
"worker startup will be slower, and 'fork' would risk the worker-pool crash.",
60+
method,
61+
)
62+
63+
64+
def main() -> None:
65+
"""Main entry point for the JABS video labeling and classifier GUI.
66+
67+
Takes one optional positional argument: path to a project directory to open.
68+
"""
69+
_configure_logging()
70+
_select_start_method()
71+
72+
# Deferred imports (see module docstring). The QtWebEngine flags must be set
73+
# before importing anything that pulls in QtWebEngine (jabs.ui), so these
74+
# os.environ lines stay above the imports below.
75+
os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = (
76+
"--disable-skia-graphite --disable-logging --log-level=3"
77+
)
78+
os.environ["QT_LOGGING_RULES"] = "qt.webenginecontext=false"
79+
from PySide6 import QtWidgets
80+
from PySide6.QtGui import QIcon
81+
82+
from jabs.core.constants import APP_NAME, APP_NAME_LONG, ORG_NAME
83+
from jabs.core.utils.process_pool_manager import ProcessPoolManager
84+
from jabs.project.parallel_workers import preload_worker_modules
85+
from jabs.resources import ICON_PATH
86+
from jabs.ui import MainWindow
87+
from jabs.version import version_str
88+
7489
parser = argparse.ArgumentParser()
7590
parser.add_argument(
7691
"project_dir", nargs="?", help="Path to JABS project directory to open on startup"
7792
)
7893
parser.add_argument("--version", action="version", version=f"JABS {version_str()}")
7994
args = parser.parse_args()
8095

81-
# CRITICAL: Create and warm the process pool BEFORE QApplication
82-
# QApplication creates threads; forking after that can be unsafe
83-
logger.info("Initializing process pool (before Qt)...")
84-
logger.debug(f"multiprocessing start method: '{multiprocessing.get_start_method()}'")
85-
process_pool = ProcessPoolManager(name="JABS-AppProcessPool")
86-
if multiprocessing.get_start_method() == "fork":
87-
# on fork platforms, start the pool and wait for workers to be ready
88-
process_pool.warm_up(wait=True)
89-
else:
90-
# on non-fork platforms, start the pool without waiting, workers will be spawned on-demand
91-
process_pool.warm_up(wait=False)
92-
logger.info(f"Process pool ready ({process_pool.max_workers} workers)")
93-
94-
# Now safe to create QApplication (fork already happened)
96+
# Warm the pool up front on fork/forkserver so worker start-up (and the
97+
# initializer's module preloading) is paid once at launch, making the first
98+
# project load / training run instant. Leave spawn (e.g. Windows) lazy so it
99+
# doesn't slow GUI launch -- those workers start on first use instead.
100+
start_method = multiprocessing.get_start_method()
101+
logger.info("Initializing process pool (start method: '%s')...", start_method)
102+
process_pool = ProcessPoolManager(
103+
name="JABS-AppProcessPool", initializer=preload_worker_modules
104+
)
105+
process_pool.warm_up(wait=start_method in ("fork", "forkserver"))
106+
logger.info("Process pool ready (%d workers)", process_pool.max_workers)
107+
95108
app = QtWidgets.QApplication(sys.argv)
96109
app.setApplicationName(APP_NAME)
97110
app.setOrganizationName(ORG_NAME)
@@ -103,16 +116,14 @@ def main():
103116
main_window.show()
104117

105118
if args.project_dir is not None:
106-
# this forces the GUI to process events before opening the project
107-
# this is necessary to avoid a race condition where the main window
108-
# is not fully initialized before trying to open the project
119+
# force the GUI to process events before opening the project to avoid a
120+
# race where the main window is not fully initialized before opening
109121
QtWidgets.QApplication.processEvents()
110122
try:
111123
main_window.open_project(args.project_dir)
112124
except Exception as e:
113125
sys.exit(f"Error opening project: {e}")
114126

115-
# user accepted license terms, run the main application loop
116127
sys.exit(app.exec())
117128

118129

0 commit comments

Comments
 (0)