
결론부터 말씀드리면, n_jobs를 올렸는데 더 느려지는 현상은 대부분 joblib(또는 scikit-learn)이 만드는 프로세스 병렬화와 NumPy·SciPy가 내부적으로 쓰는 BLAS 스레드 풀이 같은 CPU 코어를 동시에 두 번 점유하기 때문에 생깁니다. 코어가 8개인데 워커 프로세스 8개가 각자 BLAS 스레드를 4개씩 더 띄우면, 실제로는 32개의 스레드가 8개 코어를 나눠 쓰게 되고 컨텍스트 스위칭 비용만 늘어납니다. 이 글에서는 이 현상이 생기는 조건과, 환경변수·threadpoolctl로 직접 확인하고 고치는 방법을 코드로 정리합니다.
n_jobs를 올렸는데 더 느려지는 이유가 뭘까요?
scikit-learn의 n_jobs나 joblib의 Parallel(n_jobs=...)는 기본적으로 loky 백엔드를 써서 별도의 파이썬 프로세스를 여러 개 띄웁니다. 각 프로세스는 완전히 독립된 메모리와 스레드 공간을 가지고 있어요.
문제는 이 프로세스들 안에서 NumPy, SciPy, pandas가 행렬 연산을 할 때마다 내부적으로 OpenBLAS나 MKL 같은 BLAS 라이브러리를 호출한다는 점입니다. 이 BLAS 라이브러리는 자기 혼자 돌아갈 때는 가용 코어 수만큼 스레드를 알아서 띄우도록 기본 설정돼 있습니다. 즉 바깥에서 n_jobs로 프로세스를 나눠놓아도, 그 안에서 BLAS가 “코어가 이만큼 있구나”라고 다시 판단해서 스레드를 또 만드는 거예요. 이걸 오버서브스크립션(oversubscription)이라고 부릅니다.
BLAS 스레드와 joblib 프로세스가 코어를 두 번 씁니다
구체적인 수치로 보면 이렇습니다. 8코어 머신에서 RandomForestClassifier(n_jobs=8)을 돌린다고 하면, joblib이 8개 프로세스를 만들고 각 프로세스 안에서 트리 분기마다 NumPy 연산이 발생합니다. 이때 OpenBLAS가 기본값으로 코어 수(8)만큼 스레드를 쓰려고 하면 8×8=64개 스레드가 8개 코어를 다투게 됩니다.
이 상황에서는 운영체제 스케줄러가 스레드를 계속 코어에 올렸다 내렸다 하면서 컨텍스트 스위칭 비용이 커지고, 캐시도 자꾸 비워지면서 느려집니다. 코어 수보다 적은 n_jobs(예: 4)를 줬을 때보다 오히려 느린 경우가 바로 이런 패턴이에요. 병렬화 단위를 프로세스와 스레드, 두 층에서 동시에 걸어버린 게 원인입니다. 이 스레드 경쟁 문제는 운영체제의 스케줄링과도 맞물려 있는데, 멀티스레딩 환경에서 코어보다 스레드가 많아질 때 생기는 전형적인 자원 경쟁 패턴입니다.
지금 내 환경에서 몇 개의 BLAS 스레드가 떠 있는지 확인하기

직접 벤치마크 수치를 재지 않더라도, 내 환경에서 BLAS가 몇 스레드를 쓰도록 설정돼 있는지는 threadpoolctl로 바로 확인할 수 있습니다. 이 라이브러리는 scikit-learn 1.0 이후 내부적으로도 쓰이는 공식 패키지예요.
import threadpoolctl
import numpy as np
a = np.random.rand(2000, 2000)
_ = a @ a # BLAS 호출을 한 번 발생시켜서 풀을 초기화
info = threadpoolctl.threadpool_info()
for item in info:
print(item["internal_api"], item["num_threads"])
위 코드를 돌리면 환경에 따라 openblas 8이나 mkl 16처럼 라이브러리 이름과 현재 설정된 스레드 수가 출력됩니다. 이 숫자에 n_jobs 값을 곱해서 CPU 코어 수를 넘어가면 오버서브스크립션 구간에 들어간 것으로 볼 수 있어요. 코어 수는 os.cpu_count()로 확인하시면 됩니다.
환경변수로 BLAS 스레드 수를 직접 제한해 보세요
가장 간단한 해결책은 BLAS가 쓰는 스레드를 1개로 고정해서 병렬화를 전부 joblib 쪽(프로세스 층)에만 맡기는 것입니다. 이 방식은 BLAS 라이브러리가 내부 스레드 풀을 초기화하기 전, 즉 NumPy를 import하기 전에 환경변수를 설정해야 적용됩니다.
import os
os.environ["OMP_NUM_THREADS"] = "1"
os.environ["OPENBLAS_NUM_THREADS"] = "1"
os.environ["MKL_NUM_THREADS"] = "1"
import numpy as np
from sklearn.ensemble import RandomForestClassifier
from sklearn.datasets import make_classification
X, y = make_classification(n_samples=20000, n_features=50, random_state=0)
clf = RandomForestClassifier(n_estimators=300, n_jobs=os.cpu_count(), random_state=0)
clf.fit(X, y)
터미널에서 스크립트를 실행하기 전에 미리 export OPENBLAS_NUM_THREADS=1처럼 셸에서 지정해 두는 방법도 있습니다. 이렇게 하면 joblib이 코어 수만큼 프로세스를 나눠 쓰고, 각 프로세스 안의 BLAS는 스레드를 추가로 늘리지 않아서 전체 스레드 수가 코어 수를 넘지 않게 됩니다.
threadpoolctl로 코드 안에서 동적으로 제어하는 방법

환경변수는 스크립트 시작 전에 고정해야 하는 제약이 있어서, 한 노트북 세션 안에서 여러 작업을 번갈아 돌릴 때는 불편합니다. 이럴 때는 threadpoolctl.threadpool_limits를 with 블록으로 써서 특정 구간만 BLAS 스레드를 제한할 수 있습니다. 이 기능은 joblib 공식 문서에서도 병렬 작업 안의 BLAS 중첩 문제를 설명하면서 권장하는 방식이에요.
from threadpoolctl import threadpool_limits
from joblib import Parallel, delayed
import numpy as np
def heavy_dot(n):
a = np.random.rand(n, n)
return (a @ a).sum()
with threadpool_limits(limits=1, user_api="blas"):
results = Parallel(n_jobs=8)(delayed(heavy_dot)(1500) for _ in range(8))
print(len(results))
user_api="blas"로 지정하면 OpenMP나 다른 스레드 풀은 건드리지 않고 BLAS 계열(OpenBLAS, MKL)만 1스레드로 묶습니다. 세 가지 방식을 정리하면 아래와 같습니다.
| 방식 | 적용 범위 | 장점 | 주의할 점 |
|---|---|---|---|
| 환경변수(OMP_NUM_THREADS 등) | 프로세스 전체, 스크립트 실행 전 | 설정이 가장 단순함 | NumPy import 이후에 설정하면 적용이 안 될 수 있음 |
| threadpoolctl.threadpool_limits | with 블록 범위 | 세션 중간에 켜고 끌 수 있음 | 내가 쓰는 BLAS 백엔드 이름을 알아야 함 |
| joblib 백엔드를 threading으로 변경 | Parallel 호출 단위 | 프로세스 생성 비용이 없음 | 순수 파이썬 반복 구간은 GIL 때문에 효과가 거의 없음 |
병렬 처리를 어디에 맡길지 정하는 건 결국 joblib의 백엔드 선택과 BLAS 스레드 수를 같이 조정하는 문제입니다. 이 부분은 joblib 공식 문서의 병렬 처리 가이드에 백엔드별 차이가 정리돼 있어서 직접 보시면 어떤 경우에 loky 대신 threading을 쓰는 게 나은지 판단하기 편합니다.
이 설정이 오히려 손해인 경우는 언제인가요?
이 방법이 항상 이득인 건 아닙니다. 데이터셋이 작거나 모델이 가벼워서 각 워커가 처리할 작업량 자체가 적으면, 프로세스를 여러 개 띄우는 오버헤드(생성·피클링·통신 비용)가 BLAS 스레드 중첩보다 더 큰 손해일 수 있습니다. 이런 경우엔 n_jobs를 낮추거나 아예 n_jobs=1로 두고 BLAS 스레드만 코어 수만큼 쓰는 쪽이 더 빠를 때도 있어요.
또 하나, 순수 파이썬 루프처럼 BLAS 연산이 거의 없는 작업에서는 BLAS 스레드 수를 1로 줄여도 속도 차이가 안 나는 게 당연합니다. 이 글의 방법은 NumPy·SciPy·pandas의 선형대수 연산 비중이 큰 작업(랜덤포레스트, 그래디언트부스팅, 큰 행렬곱 등)에서 효과가 분명하고, 그렇지 않은 작업까지 일반화하면 오히려 설정만 복잡해질 수 있습니다. 그리고 GIL(전역 인터프리터 락) 때문에 스레드 기반 병렬화가 원래부터 효과가 제한적인 구간에서는, BLAS 스레드 조정보다 n_jobs 자체를 줄이는 쪽이 먼저 검토할 대상입니다.
지금 코드에서 n_jobs를 올렸는데도 느려지는 걸 확인하셨다면, 가장 먼저 threadpool_info()로 현재 BLAS 스레드 수를 찍어보시고, 환경변수나 threadpool_limits로 1로 묶은 다음 같은 작업을 다시 돌려서 wall time을 비교해 보시길 권해 드립니다. 코어 수, n_jobs 값, BLAS 스레드 수 세 숫자를 곱한 값이 실제 코어 수보다 훨씬 크다면, 그 차이만큼 줄여나가는 게 다음 단계입니다.
