n_jobs를 올렸는데 더 느려지는 이유, BLAS 스레드 중첩 문제

n_jobs를 올렸는데 더

결론부터 말씀드리면, 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 스레드가 떠 있는지 확인하기

멀티코어 CPU와 병렬 처리 서버 환경

직접 벤치마크 수치를 재지 않더라도, 내 환경에서 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 스레드 수 세 숫자를 곱한 값이 실제 코어 수보다 훨씬 크다면, 그 차이만큼 줄여나가는 게 다음 단계입니다.

도커 이미지 빌드가 매번 느려질 때, 캐시 순서부터 확인하세요

참고: n_jobs를 올렸는데 더 — 위키백과

Leave a Comment