Skip to content

Commit 29d4f58

Browse files
committed
Constrain native math library threading to fix XGBoost stalling in containers
Found the actual difference between "works locally, hangs on the Space": locally numpy uses Apple's Accelerate framework, unaffected by OMP_NUM_THREADS; on Linux it uses OpenBLAS, governed by its own OPENBLAS_NUM_THREADS. Neither our env-var setup nor the fitted XGBClassifier's n_jobs=None was actually constraining thread count on Linux, so a single prediction could spawn as many native threads as the *host* reports — potentially far more than a cgroup-limited container's actual CPU quota, which starves/stalls the process. - config.py now also sets OPENBLAS_NUM_THREADS, MKL_NUM_THREADS, NUMEXPR_NUM_THREADS, VECLIB_MAXIMUM_THREADS, BLIS_NUM_THREADS. - These only take effect if set before the native libraries are first loaded, so reordered imports in protac_splitter.py and the app script to import protac_splitter.config before pandas/rdkit/datasets (and, in the app, before gradio, which pulls those in transitively). - GraphEdgeClassifier.load() now forces the fitted classifier's n_jobs to 1 explicitly, since that default is baked into the pickled model at training time and env vars alone don't override XGBoost's own internal thread pool.
1 parent 4e8bf19 commit 29d4f58

4 files changed

Lines changed: 40 additions & 10 deletions

File tree

protac_splitter/config.py

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,20 @@
66

77
load_dotenv()
88

9-
# XGBoost with OpenMP can deadlock on macOS ARM64 when spawning multiple threads
10-
# during model deserialization. setdefault respects any existing user override.
11-
os.environ.setdefault("OMP_NUM_THREADS", "1")
9+
# Native math libraries (OpenMP, OpenBLAS, MKL, Apple Accelerate) each read their own
10+
# thread-count env var and default to "use every core the process can see" — which on
11+
# a cgroup-limited container can wildly exceed the actual CPU quota and turn a
12+
# millisecond-scale single-row prediction into a multi-minute stall. setdefault
13+
# respects any existing user override.
14+
for _threads_var in (
15+
"OMP_NUM_THREADS",
16+
"OPENBLAS_NUM_THREADS",
17+
"MKL_NUM_THREADS",
18+
"NUMEXPR_NUM_THREADS",
19+
"VECLIB_MAXIMUM_THREADS",
20+
"BLIS_NUM_THREADS",
21+
):
22+
os.environ.setdefault(_threads_var, "1")
1223

1324

1425
def get_cache_dir() -> Path:

protac_splitter/graphs/edge_classifier.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,16 @@ def save(self, path: Union[str, Path]):
137137

138138
@classmethod
139139
def load(cls, path: Union[str, Path]) -> "GraphEdgeClassifier":
140-
return joblib.load(str(path))
140+
model = joblib.load(str(path))
141+
# The fitted estimator's n_jobs (None = auto-detect all cores) is baked in at
142+
# training time. On cgroup-limited containers where the reported core count
143+
# doesn't match the actual CPU quota, that over-provisions native threads and
144+
# can make a single prediction take orders of magnitude longer than it should.
145+
# A single row gains nothing from parallelism anyway, so force it to 1.
146+
clf = model.pipeline.named_steps.get("clf")
147+
if clf is not None and hasattr(clf, "set_params"):
148+
clf.set_params(n_jobs=1)
149+
return model
141150

142151
@staticmethod
143152
def extract_graph_features(

protac_splitter/protac_splitter.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,21 @@
11
import hashlib
22
import logging
33
import warnings
4-
import requests
54
from pathlib import Path
65
from typing import Union, Optional, Dict, List, Literal
76

7+
# Import first, before numpy-linked packages (rdkit/datasets/pandas below): this sets
8+
# thread-count env vars for native math libraries (OpenMP/OpenBLAS/MKL/Accelerate),
9+
# which some of them only read once, at first load. Importing it after would be too
10+
# late and leave those libraries free to over-provision threads on cgroup-limited
11+
# containers.
12+
from protac_splitter.config import get_cache_dir, get_hf_token
13+
14+
import requests
815
from rdkit import Chem
916
from datasets import Dataset
1017
import pandas as pd
1118

12-
from protac_splitter.config import get_cache_dir, get_hf_token
1319
from protac_splitter.chemoinformatics import canonize
1420
from protac_splitter.evaluation import split_prediction
1521
from protac_splitter.fixing_functions import fix_prediction

scripts/protac_splitter_app.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,16 +24,20 @@
2424
from pathlib import Path
2525
from typing import Union
2626

27+
# Import protac_splitter before gradio/pandas/rdkit below: it sets thread-count env
28+
# vars for native math libraries (see protac_splitter/config.py), which some of them
29+
# only read once, at first load — importing it after gradio (which pulls in numpy
30+
# transitively) would be too late.
31+
from protac_splitter import split_protac
32+
from protac_splitter.config import get_cache_dir
33+
from protac_splitter.evaluation import split_prediction
34+
2735
from PIL import Image
2836
import gradio as gr
2937
import pandas as pd
3038
from rdkit import Chem
3139
from rdkit.Chem import Draw
3240

33-
from protac_splitter import split_protac
34-
from protac_splitter.config import get_cache_dir
35-
from protac_splitter.evaluation import split_prediction
36-
3741
# HF Spaces sets SPACE_ID automatically; cap parallelism on the (limited) free tier.
3842
IS_HF_SPACE = os.environ.get("SPACE_ID") is not None
3943
MAX_NUM_PROC = 2 if IS_HF_SPACE else 8

0 commit comments

Comments
 (0)