← 기사 목록으로
July 1, 2026
5분 소요

백테스트 엔진 속도 사다리: 노트북 CPU에서 298배, 마지막 트레이드까지 동일한 PnL

백테스트 엔진 속도 사다리: 노트북 CPU에서 298배, 마지막 트레이드까지 동일한 PnL
#algotrading
#backtest
#performance
#numba
#vectorization
#optimization

"환상 없는 백테스트" 시리즈의 일부입니다.

📄 이 글은 하나의 연구 논문으로 발전했습니다. 하나의 경로 의존적 백테스트 커널을 다섯 가지 방식으로 구현했습니다 — 단순한 pandas부터 병렬 numba 커널까지 — 모든 단계가 콤보별로 동일한 PnL을 내는지 교차 검증되어, 달라지는 것은 오직 속도뿐입니다. 논문은 speed-ladder.marketmaker.cc에서 온라인으로(인터랙티브 버전 + PDF) 읽을 수 있으며, 코드와 데이터는 github.com/suenot/backtest-speed-ladder에 있습니다.

70초. 이것이 단순한 참조 구현이 하나의 이동평균 전략의 80개 파라미터 조합을 150,000개 바에 걸쳐 스윕하는 데 걸리는 시간입니다: 지표에는 pandasrolling().apply(), 트레이드에는 평범한 Python 루프. 실제 리서치 코드의 상당 부분이 이 프로필로 돌아갑니다 — 전략을 뻔한 방식으로 작성하면 자연스럽게 이 프로필이 나오기 때문입니다.

동일한 스윕이, 동일한 노트북에서, 마지막 트레이드까지 모든 조합에 대해 동일한 PnL을 내는 데 걸리는 시간: 0.23초.

이 두 숫자 사이의 격차 — 측정된 298배 — 가 이 글의 주제입니다. 그중 단 1퍼센트포인트도 새로운 하드웨어에서 온 것이 아닙니다. GPU는 전혀 관여하지 않았습니다(사실 이 기기에는 CUDA 의미의 GPU가 아예 없습니다). 사다리의 모든 단계는 동일한 전략, 동일한 데이터, 동일한 수수료, 동일한 트레이드 수이며, 어느 구현이든 콤보별 결과가 어긋나면 전체 벤치마크를 실패시키는 동등성 게이트로 검증되었습니다. 바뀐 것은 오직 작업을 어떻게 표현하는가입니다: 무엇이 인터프리터에서 돌고, 무엇이 컴파일되어 돌고, 무엇이 병렬로 도는지. 그리고 의도적으로 느린 기준선은 어떤 헤드라인 숫자든 부풀릴 수 있으므로, 미리 숫자 하나를 더 밝혀둡니다: 유능한 벡터화 numpy 구현 — 강한 numpy 프로그래머라면 내놓을 코드 — 을 상대로도, 완성된 엔진은 여전히 약 13배 빠릅니다.

파라미터 탐색이 느릴 때, 반사적으로 찾게 되는 것은 더 큰 하드웨어입니다 — GPU, 클러스터, 클라우드 예산. 이 실험이 측정한 현실은 훨씬 덜 화려한 곳을 가리킵니다: 병목은 엔진(윈도우당 Python 호출을 하는 인터프리트된 내부 루프)과 오케스트레이션(독립적인 콤보들을 한 코어에서 순차적으로 돌리는 것)이었습니다. 둘 다 오후 한나절이면, 이미 가지고 있는 기기에서, 결과의 변화 없이 고칠 수 있습니다.

전체 사다리를 미리 보여드립니다. 아래는 각 단계의 해부입니다.

단계 구현 실제 소요 시간 속도 향상 콤보/초
M0 pandas: rolling.apply + Python 바 루프 69.92초 1.0배 1.1
M1 numpy: 슬라이딩 윈도우 WMA + 벡터화된 트레이드 3.07초 22.7배 26.0
M2 numba: @njit WMA + @njit 이벤트 루프 1.98초 35.3배 40.4
M3 numba prange: 콤보 전반의 스레드 0.32초 217.6배 248.9
M4 프로세스 풀 + numba: 콤보 전반의 프로세스 0.23초 297.9배 340.9

Apple M2 Max(12코어), Python 3.14.6, numpy 2.4.3, numba 0.64.0, BLAS(Accelerate)는 단일 스레드로 고정하여 단일 스레드 단계가 진짜로 단일 코어가 되도록 했습니다. 150,000개 바 × 80개 콤보, 3회 중 최선의 실제 소요 시간, JIT 워밍업은 제외. pandas 기준선을 포함한 모든 단계가 전체 시간이 측정되었고, 80개 콤보 전체에서 콤보별 PnL과 트레이드 수가 동일함이 검증되었습니다.

하나의 커널, 다섯 가지 구현

하나의 계단을 이루는 다섯 개의 단: 동일한 백테스트 커널이 70초짜리 pandas 기준선에서 0.23초짜리 병렬 numba 실행까지 올라가며, 각 단계는 동일한 PnL을 내는 것으로 검증됩니다

속도 비교가 의미를 가지려면 계산되는 대상이 정확히 고정되어야 하고, 모든 구현이 그것을 계산한다는 것이 증명되어야 합니다. 그래서 이 실험은 하나의 전략 커널을 고정하고 다섯 단계 전부에서 그것을 일정하게 유지합니다.

커널은 HMA/HMA3 크로스입니다 — 두 개의 Hull 스타일 이동평균 위에서 동작하는 스톱-앤-리버스(stop-and-reverse) 시스템입니다. 기본 구성 요소는 가중 이동평균입니다:

WMAp(x)i=j=1pjxip+jj=1pj\mathrm{WMA}_p(x)_i = \frac{\sum_{j=1}^{p} j \cdot x_{i-p+j}}{\sum_{j=1}^{p} j}

Hull 이동평균은 지연을 줄이기 위해 이것을 세 번 합성합니다:

HMAn(x)=WMAn(2WMAn/2(x)WMAn(x))\mathrm{HMA}_n(x) = \mathrm{WMA}_{\lfloor\sqrt{n}\rceil}\Big(2\,\mathrm{WMA}_{\lfloor n/2\rceil}(x) - \mathrm{WMA}_{n}(x)\Big)

그리고 HMA3는 대략 n/6n/6, n/4n/4, n/2n/2의 WMA로 구성되고 한 번 더 평활화되는 더 부드러운 형제 지표입니다. 파라미터 조합 하나당 여섯 개의 서로 다른 윈도우 길이에 걸친 일곱 번의 WMA 패스입니다 — 장난감이 아니라 실제 지표 스택입니다.

트레이딩 규칙은 의도적으로, 유용하게 상태를 가집니다: HMA가 HMA3보다 아래면 롱, 그 반대면 숏이며, 첫 번째로 확정된 방향에서 포지션을 엽니다. 크로스가 발생할 때마다 포지션을 청산하고, 0.09%의 왕복 수수료를 뺀 PnL을 기록하고, 반전합니다. 포지션은 바를 넘어 이어집니다 — 바 ii에서 무엇을 하는지는 마지막 크로스 이후 누적된 상태에 달려 있습니다. 이 경로 의존성이 이 실험의 핵심입니다: 백테스트를 일반적인 데이터프레임 파이프라인과 다르게 만드는 속성이며, (측정하겠지만) GPU 질문을 복잡하게 만듭니다 — 다만 통념이 말하는 방식은 아닙니다.

숫자를 판단할 수 있도록, 나머지 설정입니다:

  • 데이터: 시드(seed=42)로 고정된, 150,000개 바의 합성 기하 브라운 운동. 여기서 성능은 배열 크기와 윈도우 길이에 좌우되지, 어떤 가격 경로를 넣느냐에 좌우되지 않습니다 — 그리고 합성 시계열은 전체 실험을 결정론적으로 만들고 누구나 재현할 수 있게 합니다.
  • 그리드: [6,200][6, 200] 구간에 퍼진 80개의 서로 다른 HMA 길이 — 그래서 스윕에는 저렴한 짧은 윈도우 콤보와 비싼 긴 윈도우 콤보가 실제 그리드처럼 함께 들어 있습니다.
  • 타이밍: 실제 소요 시간, 단계마다 3회 중 최선, JIT 컴파일은 타이머 바깥에서 워밍업하고 풀 워커도 시계가 시작되기 전에 워밍업합니다. pandas 기준선을 포함한 모든 단계가 80개 콤보 전체에서 전체 시간이 측정됩니다. BLAS(Apple의 Accelerate)는 단일 스레드로 고정되어 있어서, 단일 스레드 단계는 진짜로 단일 코어입니다: numpy 단계가 비교 뒤에서 몰래 자신의 행렬-벡터 곱을 멀티스레드로 돌리는 일은 없습니다.
  • 동등성 게이트: 타이밍이 끝난 뒤, 모든 단계의 콤보별 (PnL, 트레이드 수) 벡터를 참조값과 비교합니다 — 트레이드 수는 정확히 일치해야 하고, PnL은 절대 오차 10610^{-6} 퍼센트포인트 이내여야 합니다. 커밋된 실행 결과는 pandas 기준선을 포함한 모든 단계에서 80개 콤보 전부에 대해 all_ok: true를 보고합니다. 이 게이트가 실패하면 벤치마크 자체가 성립하지 않습니다 — 그저 다섯 개의 프로그램이 다섯 가지 다른 것을 다섯 가지 다른 속도로 계산하고 있을 뿐이며, 이것이 "우리 엔진은 100배 빠르다"는 주장 상당수가 조용히 작동하는 방식입니다.

동등성 블록에 나온 숫자 하나는 잠시 정직하게 짚고 넘어갈 가치가 있습니다: 첫 번째 콤보의 지문(fingerprint)은 57,029번의 트레이드에 걸쳐 −5165.58 퍼센트포인트의 PnL입니다. 이것은 부끄러워할 전략 결과가 아닙니다 — 이것은 가장 짧은 HMA 길이(6)가 무작위 걸음의 거의 모든 흔들림마다 뒤집히면서 매번 0.09%를 지불한, 정확히 그래야 하는 모습입니다. 이것은 수익성 지표가 아니라 정확성 지문입니다. 여기서 알파를 읽어내지 마십시오. 결정론을 읽어내십시오 — 다섯 개의 구현이 동일한 57,029번의 트레이드와 소수점 여섯 자리까지 동일한 PnL에 도달하는 것, 그것이 여기서 "동일하다"는 말의 의미입니다.

이것이 확립되었으니, 아래의 모든 속도 향상은 순수한 속도입니다. 근사로 얼버무린 것은 아무것도 없습니다.

단계 M0: 단순한 pandas 프로필 — 69.9초

단순한 pandas 기준선의 해부: rolling.apply 윈도우가 150,000개 바 하나하나마다 Python 람다 호출을 생성하는 동안 인터프리터 루프가 그 아래를 기어가는 모습

이 기준선은 허수아비가 아닙니다. pandas 문서가 제안하는 방식으로 WMA를 작성하고, 전략 설명을 읽는 그대로 이벤트 루프를 작성했을 때 나오는 코드입니다:

def pd_wma(s: pd.Series, period: int) -> np.ndarray:
    w = np.arange(1, period + 1, dtype=np.float64)
    w /= w.sum()
    return s.rolling(period).apply(lambda x: np.dot(x, w), raw=True).to_numpy()

def run_pandas_one(close, length):
    h, h3 = pd_hma(close, length), pd_hma3(close, length)  # 7 rolling.apply WMAs
    total, ntr, prev_dir, entry, pos = 0.0, 0, 0, 0.0, 0
    for i in range(len(close)):                            # Python bar loop
        if np.isnan(h[i]) or np.isnan(h3[i]):
            continue
        d = 1 if h[i] < h3[i] else -1
        if prev_dir == 0:
            prev_dir, pos, entry = d, d, close[i]
            continue
        if d != prev_dir:                                  # cross: close + reverse
            pnl = ((close[i] - entry) if pos == 1
                   else (entry - close[i])) / entry * 100 - FEE
            total += pnl
            ntr += 1
            pos, entry, prev_dir = d, close[i], d
    return total, ntr

왜 이것이 느릴까요? pandas가 "나쁘기" 때문이 아니라, 반복이 어디에서 일어나는지 때문입니다. rolling(period).apply(lambda ...)는 벡터화된 옷을 입은 Python 수준의 루프입니다. 150,000개 바 하나하나마다, pandas는 윈도우를 구체화하고, C/Python 경계를 넘고, Python 콜러블을 호출하고, 결과를 박싱합니다. raw=True를 써도(적어도 람다에 Series 대신 순수 ndarray를 넘겨줍니다), 호출당 인터프리터 오버헤드가 그 윈도우가 실제로 필요로 하는 수십에서 수백 FLOP를 압도합니다. 콤보당 일곱 번의 WMA 패스를 곱하면, 지표 스택만으로도 수백만 번의 인터프리터 왕복입니다. 그다음 바 루프가 콤보당 또 다른 150,000번의 인터프리트된 반복을 돌리며, 매번 numpy 스칼라에 대해 범위 검사가 붙은 인덱싱을 하고, float를 박싱하고, 인터프리터가 매번 새로 발견하는 타입에 대해 동적으로 디스패치합니다.

결과: 스윕에 69.92초, 콤보당 약 0.87초, 초당 1.1개 콤보의 처리량. 80개 콤보 그리드에서는 그저 어깨를 으쓱하고 1분 기다리면 됩니다. 문제는 아무도 80개 콤보 그리드를 오래 돌리지 않는다는 것입니다 — 그리고 이 비용은 영원히 선형적으로 커집니다. 이 이야기는 다시 나옵니다.

단계 M1: numpy — 루프 안에서 Python을 부르지 마라 — 3.07초, 22.7배

첫 번째 단계 상승은 두 인터프리터 루프를 동시에 없애며, 두 트릭을 분리해서 볼 가치가 있습니다. 일반성이 매우 다르기 때문입니다.

지표 쪽은 쉽고 완전히 일반적인 쪽입니다. 모든 윈도우에 걸친 가중 이동평균은 입력의 스트라이드 뷰에 대한 행렬-벡터 곱일 뿐입니다 — 복사 없이, 한 번의 BLAS 호출로:

def vec_wma(x: np.ndarray, period: int) -> np.ndarray:
    w = np.arange(1, period + 1, dtype=np.float64)
    win = np.lib.stride_tricks.sliding_window_view(x, period)  # zero-copy view
    out = np.full(len(x), np.nan)
    out[period - 1:] = win @ w / w.sum()                       # one matvec
    return out

sliding_window_view는 동일한 메모리에 대해 (n − p + 1, p) 뷰를 만들고, win @ w는 컴파일된 코드에서 모든 윈도우의 내적을 계산합니다. 백만 번의 람다 호출이 하나의 라이브러리 호출이 됩니다.

트레이드 쪽은 더 흥미롭습니다. 이벤트 루프가 상태를 가지는데도 — 커널에 한해서는 — 벡터화되기 때문입니다. 통찰은, 임의의 바에서 포지션은 HMA − HMA3의 부호에만 의존할 뿐, 어떤 트레이드 결과에도 의존하지 않는다는 것입니다. 상태가 결정으로 다시 피드백되지 않습니다. 그래서 전체 루프가 "부호가 뒤집히는 지점을 찾고, 그 인덱스의 가격을 모으는" 것으로 붕괴합니다:

d = np.where(h[idx] < h3[idx], 1, -1)             # direction per valid bar
flips = np.flatnonzero(np.diff(d) != 0) + 1       # bars where it crosses
cross = idx[np.concatenate(([0], flips))]         # entry/exit indices
side  = d[np.concatenate(([0], flips))]
entries, exits, s = close[cross[:-1]], close[cross[1:]], side[:-1]
pnl = np.where(s == 1, (exits - entries) / entries,
               (entries - exits) / entries) * 100 - FEE
return float(pnl.sum()), int(pnl.size)

3.07초, 22.7배 속도 향상, 초당 26.0개 콤보 — 단일 스레드로 고정된 BLAS로, 한 코어에서. 이 단계는 이름표를 붙일 만합니다: 이것은 유능한 기준선, 즉 강한 numpy 프로그래머라면 내놓을 구현이며, 그 위의 모든 것을 재는 공정한 잣대입니다. 다만 이 단계에는 두 가지 정직한 유보 조건이 따라붙습니다.

첫째, 이 벡터화는 전략 특이적인 분석적 재작성이지, 기계적인 변환이 아닙니다. 이것이 가능한 이유는 커널이 스톱도, 트레일링 청산도, 진행 중인 PnL에 의존하는 포지션 사이징도 없는 스톱-앤-리버스이기 때문입니다. 스톱로스 하나 — 가장 평범하게 상상할 수 있는 기능 — 를 추가하면, 바 ii에서의 청산이 바 j>ij > i에 어떤 진입이 존재하는지를 바꾸고, 상태가 경로로 피드백되어, 닫힌 형식(closed form)이 증발합니다. 대부분의 프로덕션 커널은 그 경계선의 잘못된 쪽에 있습니다.

둘째, 이 단계는 정확성이 죽으러 가는 곳입니다. 플립-인덱스 부기(여기서는 +1, 저기서는 [:-1], 첫 방향 시딩)는 정확히 오프바이원 실행 버그를 만드는 종류의 코드입니다 — 우리의 미래 참조 편향 분류 체계가 노이즈에서 Sharpe 15를 만들어낼 수 있음을 보여준 것과 같은 종류의 버그입니다. 동등성 게이트는 이 단계에서 형식적인 절차가 아닙니다 — 이것을 신뢰할 수 있는 유일한 이유입니다. 단순한 참조 구현에 대한 동등성 확인이 없는 영리한 벡터화 재작성이야말로, 엔진이 자신이 테스트한다고 주장하는 전략에서 조용히 멀어지는 방식입니다.

단계 M2: numba — 실제로 쓰고 싶은 루프를 컴파일하라 — 1.98초, 35.3배

numba JIT 컴파일러를 통과하여 촘촘한 머신 코드로 나오는 Python 이벤트 루프: 동일한 분기가 많은 바 단위 로직이, 인터프리트되는 대신 컴파일된 모습

단계 M2는 정반대 철학을 취합니다: 알고리즘을 벡터화된 원시 함수에 맞게 비틀지 않고, 단순한 루프를 그대로 쓰고 — 컴파일합니다. Numba(Lam, Pitrou & Seibert, 2015)는 Python의 수치 부분집합을 LLVM을 거쳐 머신 코드로 JIT 컴파일합니다:

@njit(cache=True)
def nb_wma(x, period):
    n = x.shape[0]
    out = np.full(n, np.nan)
    wsum = period * (period + 1) / 2.0
    for i in range(period - 1, n):        # the "slow" loop, now machine code
        s = 0.0
        for j in range(period):
            s += x[i - period + 1 + j] * (j + 1)
        out[i] = s / wsum
    return out

@njit(cache=True)
def nb_sweep(close, half, full, sq, p3, p2, pi, fee):
    h  = nb_wma(2.0 * nb_wma(close, half) - nb_wma(close, full), sq)
    a  = 3.0 * nb_wma(close, p3) - nb_wma(close, p2) - nb_wma(close, pi)
    h3 = nb_wma(a, pi)

nb_sweep 내부의 이벤트 루프는 텍스트 그대로 M0의 루프입니다. 분기, continue, 로컬 변수에 담긴 상태 — 전부 그대로입니다. @njit 아래에서는 그 로컬 변수들이 레지스터에 살고, 분기는 실제 점프 명령이 되며, 반복당 비용은 마이크로초 단위의 인터프리터 디스패치에서 나노초 단위로 떨어집니다.

1.98초 — pandas 대비 35.3배지만, numpy 대비로는 약 1.6배에 불과합니다(도출: 3.07/1.98). 이 소박한 단계 자체가 시사하는 바가 있습니다: numpy의 내부 루프는 이미 컴파일되어 있었으므로, 피처 수치 계산에서 numba가 얻는 승리는 윈도우 구체화와 중간 배열 생략에 국한됩니다. 진짜로 판을 바꾸는 부분은 다른 곳에 있습니다:

  1. 이벤트 루프가 이제 공짜가 됩니다 — 그리고 "공짜"는 수사가 아니라 측정된 값입니다. M1은 트레이드 로직을 벡터화 가능하게 만드는 데 영리함을 소모했습니다. M2는 그 영리함 자체를 불필요하게 만듭니다 — 단순하고, 감사하기 쉽고, 수정하기 쉬운 루프가 머신 속도로 돌아갑니다. 이 컴파일된 커널 안에서 피처 단계와 트레이드 루프를 따로 측정하면, 시간의 **99.3%**가 WMA 피처 수치 계산에, 단 **0.7%**만이 상태를 가진 이벤트 루프에 귀속됩니다. 내일 당장 연구 프로젝트 없이 스톱로스를 추가할 수 있습니다 — 그리고 이 분리 비율을 기억해 두십시오. 아래에서 GPU 논의를 다시 결정하게 됩니다.
  2. 다음 두 단계를 열어줍니다. 컴파일되고, GIL을 해제하며, 할당이 가벼운 커널은 병렬 오케스트레이션이 필요로 하는 작업 단위입니다. M0는 생산적으로 병렬화할 수 없습니다 — 느린 것을 열두 벌 복사해도 여전히 느립니다. 그냥 더 따뜻해질 뿐입니다.

방법론적으로 한 가지 짚어둘 점: numba는 첫 호출 시 컴파일하며, 그 컴파일(수백 밀리초)이 타이머 안에 들어가면 안 됩니다 — 하니스는 측정 전에 500개 바짜리 슬라이스에서 JIT를 워밍업하고, cache=True는 프로세스 실행을 넘어 컴파일된 커널을 유지합니다. 이 디테일을 "깜빡한" 벤치마크는 (콜드 컴파일이 포함되어) 부당하게 나쁘거나, 재현 불가능한 numba 숫자를 내놓습니다.

단계 M3: prange — 이미 가지고 있던 병렬성 — 0.32초, 217.6배

열두 개의 CPU 코어에 팬아웃되는 여든 개의 독립적인 파라미터 콤보: 성능 코어와 효율 코어가 서로 다른 길이의 윈도우를 병렬로 끌어당기는 모습

여기서 대규모 파라미터 탐색을 특별하게 만드는 관찰이 나옵니다: 80개의 콤보는 완전히 독립적입니다. 공유 상태도, 순서도, 통신도 없습니다. 이것은 순전히 습관 때문에 열두 개 중 하나의 코어에서 M0-M2가 돌리고 있던, 부끄러울 정도로 병렬화하기 쉬운 작업입니다.

Numba는 이 수정을 거의 문법적인 수준으로 만듭니다 — 콤보 루프의 rangeprange로 바꾸면 됩니다:

@njit(parallel=True, cache=True)
def nb_sweep_all(close, params, fee):
    N = params.shape[0]
    totals = np.empty(N, dtype=np.float64)
    ntrs = np.empty(N, dtype=np.int64)
    for k in prange(N):                    # threads across combos
        t, ntr = nb_sweep(close, params[k, 0], params[k, 1], params[k, 2],
                          params[k, 3], params[k, 4], params[k, 5], fee)
        totals[k] = t
        ntrs[k] = ntr
    return totals, ntrs

nb_sweep이 nopython으로 컴파일되어 있어서 GIL을 전혀 잡지 않고, numba의 스레딩 레이어가 반복을 12개 코어 전부에 팬아웃합니다. 읽기 전용인 close 배열은 모든 스레드가 비용 없이 공유합니다.

0.32초 — pandas 대비 217.6배, 초당 248.9개 콤보. 단일 스레드인 M2 대비 이 단계의 상승 폭은 12코어에서 약 6.2배입니다(도출: 1.98/0.32). "이상적인 12배"에서 부족한 부분은 숨기기보다 정직하게 밝힐 가치가 있습니다: M2 Max의 12개 코어는 8개의 성능 코어와 4개의 효율 코어로 이루어져 있어서 명목상의 상한이 애초에 12배였던 적이 없고, 80개의 콤보는 비용이 극단적으로 불균등하며(길이 6짜리 HMA는 길이 200짜리보다 훨씬 저렴합니다) 스레드들이 들쭉날쭉하게 끝나고, 각 커널 호출은 공유 할당자에서 중간 배열을 할당합니다. 실제 기기에서의 병렬 속도 향상은 이런 모습입니다. 이질적인 작업에 대해 깔끔한 N코어-N배를 내세우는 것은 뭔가 합성된 것을 측정하고 있다는 뜻입니다.

단계 M4: 마지막 3분의 1을 위한 프로세스 풀 — 0.23초, 297.9배

마지막 단계는 스레드를 프로세스로 교체합니다 — 동일한 컴파일된 커널을, ProcessPoolExecutor로 오케스트레이션합니다:

with ProcessPoolExecutor(max_workers=12, initializer=_init_worker,
                         initargs=(close,)) as ex:          # ship data ONCE
    list(ex.map(_warmup_worker, range(12 * 3)))             # JIT-warm every worker
    results = list(ex.map(_run_one_combo, grid, chunksize=1))

0.23초 — pandas 대비 297.9배, 초당 340.9개 콤보. 이 처리량을 다시 읽어보십시오: 이 노트북은 이제 초당 대략 340번의 완전한 150,000바 백테스트를 돌리고 있으며, 각각이 일곱 개의 가중 이동평균을 계산하고 수만 번의 상태를 가진 트레이드를 시뮬레이션합니다.

prange 대비 우위는 실재하지만 소박합니다 — 약 1.4배(도출: 0.32/0.23) — 그리고 그럴듯한 메커니즘은 스케줄링과 메모리 격리입니다: chunksize=1로는 풀이 콤보를 한 번에 하나씩 나눠주므로, 저렴한 윈도우와 비싼 윈도우가 뒤섞인 들쭉날쭉한 조합이 비대칭 코어들 사이에서 동적으로 로드 밸런싱되고, 각 워커 프로세스는 자신만의 할당자를 가져서 콤보별 임시 객체에 대한 경합을 피합니다. 이것들은 측정치와 부합하는 메커니즘으로 보고하는 것이지, 별도로 증명된 사실로 보고하는 것이 아닙니다.

프로세스는 공짜가 아니며, 하니스는 그 비용을 타이머 바깥에서 정직하게 지불합니다. 그곳에서는 그것들이 일회성 비용이기 때문입니다(워커 시작, 이니셜라이저를 통해 모든 워커에 close를 실어 보내는 것, 워커별 JIT 워밍업) — 실제 탐색에서는 이 비용이 여든 개가 아니라 수천 개의 콤보에 걸쳐 상각되기 때문입니다. 정직한 일반 지침은: prange가 더 단순하고 보통은 충분합니다. 프로세스 풀은 작업이 덩어리져 있거나, 그리드가 크거나, 콤보별 작업이 numba가 닿을 수 없는 어딘가에서 GIL을 붙잡을 때 이깁니다.

이로써 사다리는 깔끔한 요약으로 분해됩니다. M0에서 M2까지는 엔진입니다: 반복을 인터프리터 밖으로 옮긴 것만으로 단일 코어에서 35.3배. M2에서 M4까지는 오케스트레이션입니다: 이미 그 자리에 있던 코어를 사용한 것만으로 또 8.4배(도출: 1.98/0.23). 곱하면: 298배. 새 하드웨어 없이, 동일한 결과로. 그리고 단순한 기준선이 아니라 유능한 M1 기준선으로부터 측정해도, 완성된 엔진은 여전히 약 13배 높습니다(도출: 3.07/0.23) — 이 사다리는 느린 출발점을 골라서 만들어진 인공물이 아닙니다.

왜 GPU가 아닌가 — 정직한 버전

포화 상태의 CPU 옆에 놀고 있는 GPU: 여든 개 콤보에 0.23초라는 스윕은 왕복 여행값을 지불하기에 너무 좁고 너무 짧아서, 배치 가능한 이동평균 수치 계산이 CPU에 남겨진 모습

"그냥 GPU로 포팅해"는 느린 파라미터 스윕에 대한 가장 흔한 반응이므로, 이 실험은 그 대화가 시작되어야 할 두 숫자를 측정합니다 — 그리고 어느 쪽도 게으른 버전의 답을 지지하지 않습니다.

루프라인 모델(Williams, Waterman & Patterson, 2009)은 커널을 산술 강도(이동한 바이트당 FLOP)로 분류합니다. 이 스윕의 WMA 피처 스택에서, 길이 pp의 윈도우 하나에 바 하나당 2p2p FLOP를 세고 바당 8바이트 읽기 하나를 대응시키면, 전체 80개 콤보 스윕은 대략 576MB를 스트리밍하며 6.2 GFLOP가 됩니다:

I=6.21×109 FLOP5.76×108 bytes10.78 FLOPbyteI = \frac{6.21 \times 10^9\ \text{FLOP}}{5.76 \times 10^8\ \text{bytes}} \approx 10.78\ \frac{\text{FLOP}}{\text{byte}}

(이것은 콤보당 여섯 개의 서로 다른 WMA 윈도우에 대한 이상화된 계산입니다. 실제로 실행되는 일곱 번의 패스를 세면 11.07 FLOP/byte가 나옵니다. 어느 쪽이든 결론은 같습니다.)

이 숫자가 중요한 이유는 그것이 배제하는 것 때문입니다: "백테스트 수치 계산은 메모리 바운드라서 GPU가 도움이 안 된다"는 널리 퍼진 주장은 여기서는 거짓입니다. 약 10.8 FLOP/byte에서 피처 수치 계산은 확실히 연산 쪽에 가깝습니다 — 일반적인 하드웨어가 대역폭 제한에서 벗어나는 능선점(ridge point)을 훌쩍 넘습니다. GPU는 80개 콤보 × 7번의 WMA 패스를 몇 개의 큰 커널로 배치하여 그 산술을 씹어 삼킬 수 있을 것입니다. 피처 스택이 문제의 전부였다면, GPU 사례는 존중받을 만했을 것입니다.

두 번째로 측정된 숫자는 다른 게으른 답을 죽입니다 — 우리 스스로도 손이 갔을 답입니다. 컴파일된 커널 내부에서 피처 단계와 트레이드 루프를 따로 측정하면 **피처 99.3%, 이벤트 루프 0.7%**의 분리 비율이 나옵니다. "백테스트에는 상태를 가진, 분기가 많은 이벤트 루프가 있고, 그것이 GPU를 막는다"는 솔깃한 주장은 여기서는 정량적으로 틀렸습니다: CPU는 사실상 시간의 전부를 GPU가 배치할 수 있는 바로 그 부분에 씁니다. 80개 콤보 × 7번의 WMA 패스를 큰 배치 컨볼루션으로 다시 짜면, 지극히 합리적인 텐서 워크로드가 됩니다. 그러니 정직한 질문은 그 작업이 GPU로 갈 수 있느냐가 아닙니다 — 대부분은 갈 수 있습니다. 질문은 그 여행이 값어치가 있느냐이며, 이 스윕에 대해서는 그렇지 않습니다. 두 가지 구체적인 이유 때문입니다:

1. 활용 가능한 폭은 80개 콤보이며 — GPU는 폭을 위한 기계입니다. 파라미터 스윕에서 유일하게 정직한 병렬성의 축은 그리드 자체입니다: 콤보 하나 안에서는 150,000바짜리 경로가 순차적입니다. GPU는 레인을 채우고 지연을 숨기기 위해 수만 개의 독립적인 작업 항목을 원하는데, 이 스윕은 여든 개를 제공합니다. 열두 개의 CPU 코어가 이미 그 폭을 포화시킵니다 — 그것이 바로 단계 M3-M4가 측정한 것입니다. GPU의 폭이 겨우 관여하기 시작할 콤보 수에서, CPU 사다리는 이미 초당 수백 번의 완전한 백테스트를 내놓고 있습니다.

2. 전체 작업이 0.23초입니다. M4 속도에서 콤보 하나는 약 2.9ms입니다(도출: 0.23초 / 80). 이 예산에 대해, 커널 실행 지연과 디바이스 동기화 지점은 상각 가능한 반올림 오차가 아니라 — 작업의 상당 부분입니다. (이 통합 메모리 Apple 기기에서는 호스트-디바이스 전송이 사소한 문제지만, 개별 GPU를 가진 CUDA 기기에서는 그것도 청구서에 추가됩니다.) 전형적인 GPU의 승리는 고정 오버헤드를 거대한 작업 배치에 걸쳐 상각합니다. 1초 미만의 스윕은 그런 것을 결코 만들어내지 못합니다.

그리고 이벤트 루프는 어떨까요? 그것은 배치되지 않을 유일한 부분입니다 — 순차적이고, 분기가 많고, 경로에 의존적이며, 콤보 안에서는 어떤 하드웨어도 병렬화할 수 없는 150,000바 길이의 루프 캐리 종속성이며, SIMT 레인이 싫어하는 바로 그런 발산 분기를 가지고 있습니다. GPU 포팅은 이것을 CPU에 남겨두거나, 콤보당 하나의 레인에서 돌려야 할 것입니다. 하지만 커널의 0.7%에 불과하니, 무엇을 결정하기에는 너무 작은 암달 항입니다. 이것은 가지 않을 부분이지, 가지 않을 이유가 아닙니다. (단계 M1을 떠올려보면, 피드백이 없는 커널의 경우 그 루프조차 분석적으로 벡터화될 수 있습니다 — 다만 전략이 스톱 하나만 자라도 잃어버리는 재작성입니다.)

완전성을 위해 플랫폼 각주 하나: 이 기기(Apple Silicon)에서 GPU 경로는 CUDA가 아니라 MLX나 PyTorch-MPS일 것입니다 — cupy와 CUDA 생태계는 아예 적용되지 않습니다 — 그리고 어느 쪽이든 이 실험을 시도하는 것만으로도 핫 패스를 텐서 방언으로 다시 작성해야 합니다. 이것은 위의 분석에 따르면 이 스윕의 형태에 대해 확인된 보상이 없는 실질적인 비용입니다. 여기서의 GPU 논의는 분석적이며, 측정된 산술 강도와 측정된 피처/루프 분리 비율에 근거합니다. 그리고 그렇게 표시해둡니다: 공개된 하드웨어에서는 불가능했기 때문에 어떤 CUDA 실행도 수행되지 않았습니다.

리뷰에서 옹호할 요약 문장은 이렇습니다: 이 작업의 거의 전부는 GPU로 갈 수 있다. 이 스윕은 그 여행이 값어치를 하기에는 너무 좁고 너무 짧을 뿐이다. 그리고 이것을 양방향으로 읽으십시오 — 완전한 부결은 아닙니다. 배치된 "큰 행렬" 재정식화 — 스윕을 수천 개의 콤보에 걸친 대규모 텐서 연산으로 다시 짜거나, 처음부터 끝까지 배치할 수 있는 진정으로 피드백 없는 커널 — 는 별도의 연구를 받을 만한 실재하고 유망한 방향이지, 기각할 대상이 아닙니다. 80개 콤보와 0.23초에서는, 그저 아직 그 표를 얻지 못했을 뿐입니다. 여러분의 워크로드가 그 폭을 가지고 있다면 산술이 바뀌니, 우리를 인용하지 말고 직접 다시 계산해보십시오.

진짜 병목이 있는 곳: 엔진과 오케스트레이션

드러난 진짜 병목: 모래시계 안에서 하드웨어가 아니라 엔진과 수천 개 파라미터 콤보의 오케스트레이션이 흐름을 막고 있는 모습

여든 개의 콤보는 시연용 그리드입니다. 실제 파라미터 탐색은 이런 요인들이 학술적인 이야기이기를 멈추는 지점입니다. 그리드는 곱셈적으로 커지기 때문입니다: 네 개의 파라미터가 각각 열 개의 값을 가지면 10410^4 콤보이고, 워크포워드 검증을 열두 개의 폴드로 추가하면 아무것도 탐색하기 전에 이미 1.2×1051.2 \times 10^5번의 완전한 백테스트에 도달합니다. 이것이 차원의 저주이며, 탐색 전략 — Optuna, 좌표 하강법, Sobol — 이 그토록 많은 주목을 받는 이유입니다: 더 똑똑한 탐색은 더 적은 지점을 방문합니다.

하지만 이 사다리는 방정식의 덜 논의되는 다른 절반을 드러냅니다: 방문한 지점당 비용. 측정된 처리량을 선형으로 외삽하면(콤보는 독립적이므로 이것은 모델링이 아니라 산술입니다):

그리드 크기 M0에서(1.1 콤보/초) M4에서(340.9 콤보/초)
10,000 콤보 약 2.4시간 약 30초
100,000 콤보 약 24시간 약 5분

단순한 엔진에서는 밤샘 배치 작업인 동일한 실험이, 튜닝된 엔진에서는 대화형 쿼리가 됩니다. 그 차이는 실제 소요 시간 표가 축소해서 보여주는 방식으로 복리처럼 쌓입니다: 스윕당 5분이면 반복합니다 — 누출을 고쳐서 다시 돌리고, 폴드를 추가하고, 그리드를 넓히고, 점심 먹다 떠오른 아이디어를 테스트합니다. 스윕당 24시간이면, 그렇게 하지 않습니다. 엔진의 속도가 리서치 루프의 템포를 정하고, 리서치 루프의 템포가 실제 산출물입니다.

사다리 전체에 대한 암달의 법칙적 해석도 있습니다:

S=1(1p)+p/sS = \frac{1}{(1 - p) + p / s}

어떤 단일 단계 pp를 계수 ss만큼 빠르게 만드는 것은, 여러분이 느리게 남겨둔 나머지 전부에 의해 한계가 정해집니다. 이 사다리는 그 순서를 존중했습니다: 35.3배의 엔진 이득은 지배적이던 항(피처 스택과 루프 양쪽 모두에서의 인터프리트된 반복)을 공격했고, 8.4배의 오케스트레이션 이득은 그다음으로 지배적이던 항(놀고 있던 열한 개의 코어)을 공격했습니다. 피처/루프 분리 비율도 축소판으로 같은 교훈입니다 — 시간이 실제로 어디로 갔는지 측정하지 않고서는 GPU 논쟁의 진짜 형태를 짚어낼 수 없었을 것입니다. 먼저 프로파일링, 그다음 최적화 — 이 순서로. 동일한 논리가 엔진보다 상류에 있는 데이터 레이어를 지배합니다: 우리의 Polars vs pandas 벤치마크는 스택의 로드-앤-변환 절반에 대해 동일한 패턴(그룹화된 롤링 파이프라인에서 10-3500배)을 발견했고, 동일한 하이브리드 결론 — 파이프라인에는 컬럼형 엔진, 경로 의존적 시뮬레이션에는 컴파일된 커널 — 이었습니다.

일반성을 매듭짓기 위한 두 가지 정직한 참고사항. 첫째, 이 실험은 의도적으로 자기완결적이고 합성적입니다 — 시드가 고정된 데이터, 하나의 커널, 하나의 공개된 기기 — 그래서 누구나 이 현상을 결정론적으로 재현할 수 있습니다. 실제 소요 시간 숫자는 여러분의 하드웨어에서 다를 것이지만, 동등성과 사다리의 방향은 그렇지 않을 것입니다. 둘째, 이 현상은 합성 설정의 인공물이 아닙니다: 우리 프로덕션 HMA 엔진의 벤치마크(bench_param_sweep.py, 실제 거래소 데이터와 전체 프로덕션 수수료·체결 모델로 실행)는 동일한 사다리 형태를 보여주며, numba 경로가 단순한 pandas 프로필보다 대략 100-200배 위에 자리합니다. 이 자기완결적인 실험이 존재하는 이유는, 여러분이 우리의 프로덕션 숫자를 그냥 믿을 필요가 없도록 하기 위해서입니다.

핵심 요점

  1. 사다리는 298배이며, 이렇게 분해됩니다: 35.3배 엔진 × 8.4배 오케스트레이션. 반복을 인터프리터 밖으로 옮기고(pandas → numba) 독립적인 콤보들을 코어 전반에 퍼뜨린 것(하나 → 열둘)이 곱해져서 바뀌지 않은 노트북 위에서 세 자릿수에 가까운 속도 향상을 냈습니다. 69.92초 → 0.23초; 1.1 → 340.9 콤보/초. 그리고 이것은 느린 기준선의 인공물이 아닙니다: 유능한 벡터화 numpy 구현 대비로도, 완성된 엔진은 여전히 약 13배입니다.
  2. 속도에 감탄하기 전에 동등성을 요구하십시오. 여기서의 모든 단계는 80개 콤보 전체에서 자동으로 게이팅된, 동일한 콤보별 PnL과 트레이드 수를 냅니다(PnL은 절대 오차 10610^{-6} 허용, 트레이드는 정확히 일치). 미묘하게 다른 것을 계산하는 빠른 엔진은 빠른 것이 아닙니다 — 높은 처리량으로 틀린 것이며, 벡터화된 재작성은 대개 그 틀림이 몰래 숨어드는 곳입니다.
  3. @njit는 상태를 가진 로직에 대해 영리한 벡터화를 이깁니다. numpy 단계는 스톱로스 하나만 추가해도 죽는, 전략 특이적인 닫힌 형식이 필요했습니다. numba 단계는 단순하고 감사하기 쉬운 루프를 그대로 컴파일합니다 — 동일한 속도 등급이면서, 그 취약함이 전혀 없고, 병렬화되는 단위이기도 합니다.
  4. GPU에 대한 답은 "이 스윕에는 아니다"입니다 — 그리고 여러분이 그 이유를 댈 수 있어야 합니다. 피처 수치 계산은 연산 쪽에 가깝고(10.78 FLOP/byte) 동시에 컴파일된 커널의 99.3%를 차지하므로, "백테스트는 메모리 바운드다"도 "상태를 가진 루프가 지배한다"도 측정 앞에서 살아남지 못합니다. 정직한 이유는 폭과 예산입니다: 12개의 CPU 코어가 이미 포화시키는 80개 콤보 분량의 활용 가능한 병렬성, 그리고 실행·동기화 오버헤드가 잡아먹을 0.23초짜리 전체 작업. 실제 폭에서의 배치된 큰 행렬 재정식화는 여전히 유망한 방향으로 남아 있으며, 기각된 것이 아닙니다.
  5. 엔진 속도가 곧 리서치 템포입니다. 단순한 엔진의 처리량에서는 100,000번의 백테스트 탐색이 하루입니다. 사다리 꼭대기의 처리량에서는 5분입니다. 하드웨어를 사거나 클러스터를 빌리기 전에, 여러분의 병목이 애초에 실리콘이긴 한지 확인하십시오 — 우리 것은 rolling.apply 안의 lambda와 놀고 있던 열한 개의 코어였습니다.

전체 실험 — 다섯 가지 구현 전부, 동등성 하니스, 루프라인 계산, 그리고 이 글의 모든 숫자가 하나의 결정론적 스크립트로 재생산 가능한 것까지 — 은 speed-ladder.marketmaker.cc의 동반 논문에 있으며, 코드와 데이터는 github.com/suenot/backtest-speed-ladder에 있습니다.

70초 걸리던 스윕이 이제 4분의 1초 걸립니다. 동일한 트레이드, 동일한 PnL, 동일한 노트북. 여러분이 막 신청하려던 GPU는 기다릴 수 있습니다. 여러분이 막 배포하려던 인터프리터 루프는 그럴 수 없습니다.

blog.disclaimer

Authors

Eugen Soloviov
Eugen Soloviov

Trading-systems engineer

Trading-systems engineer building bots since 2017: cross-exchange arbitrage (connected up to 30 venues), cointegration-based pairs arbitrage across spot and futures, scalping, news and sentiment-driven strategies, trend algorithms, and portfolio management and balancing algorithms. Also builds sub-millisecond order execution, big-data warehouses, backtesting engines, AI agents, and trading interfaces (incl. open-source profitmaker.cc). Stack: JS/TS, Python, Rust/Zig/Go, DevOps, backend, frontend, architecture.

Newsletter

시장에서 앞서 나가세요

뉴스레터를 구독하여 독점적인 AI 트레이딩 통찰력, 시장 분석 및 플랫폼 업데이트를 받아보세요.

귀하의 개인정보를 존중합니다. 언제든지 구독을 취소할 수 있습니다.