GPU 정밀도 함정: Apple Metal에서 fp32 백테스트가 조용히 쓰레기 값을 반환하는 방법
"환상 없는 백테스트" 시리즈의 일부입니다.
우리는 파라미터 스윕 백테스트를 GPU로 이식했고, 2,796배 빨라졌습니다. 반환된 숫자들은 그럴듯해 보였습니다. 하지만 처음 작동한 버전에서 그 숫자들은 완전히 쓰레기였습니다 — 200배나 어긋나 있었습니다 — 그런데도 아무것도 멈추지 않았고, 아무 경고도 뜨지 않았고, 아무것도 이상해 보이지 않았습니다.
이것이 바로 Apple 실리콘 위의 GPU 백테스트를 CPU 코드와는 다른 방식으로 위험하게 만드는 함정입니다: Metal에는 float64가 없습니다. Apple GPU에서 여러분의 백테스트가 다루는 모든 숫자는, 원했든 원하지 않았든, 32비트 float입니다. 그리고 이동평균을 벡터화하는 가장 유혹적인 방법 — 성능을 신경 쓰는 퀀트라면 누구나 손을 뻗는 O(n) 접두사 합(prefix-sum) 트릭 — 이 바로 fp32가 가격 스케일에서 살아남을 수 없는 그 정식화입니다. 오류가 나지 않습니다. 전속력으로 실행되고, 211배나 틀린 이동평균 위에 지어진, 그럴듯해 보이는 자산 곡선을 여러분 손에 쥐여줍니다.
흥미로운 부분은 해법입니다. 왜냐하면 그것은 "비트를 더 쓰라"(그럴 수 없습니다)도 아니고 "더 신중해지라"(순진한 코드는 이미 충분히 신중합니다)도 아니기 때문입니다. 해법은 동일한 수학적 값을 다른 방식의 합으로 계산하는 것입니다 — 직접 윈도우 컨볼루션 — 이는 모든 중간값을 fp32가 정확히 표현할 수 있을 만큼 작게 유지합니다. 동일한 WMA, 유효숫자 일곱 자리까지 동일한 결과, 그리고 CPU의 단일 스레드 numba보다 55.9배 빠릅니다. 이 글은 그 부검입니다: Metal이 왜 여러분에게 fp32를 강제하는지, 그 뻔한 정식화가 정확히 어디서 오버플로를 일으키는지, 올바른 정식화는 왜 그렇지 않은지, 그리고 우리가 — 곡선을 눈으로 훑어보는 게 아니라 트레이드 수를 세어 — 빠른 버전과 정직한 fp64 버전이 일치함을 어떻게 증명했는지.
여기 나오는 모든 숫자는 Apple M2 Max에서, 150,000개 바 × 80개 파라미터 조합으로, 3회 중 최선값으로 측정되었으며, 출처는 저장소의 scripts/bench_param_sweep.py(M5 GPU 방식, 커밋 97eadaf), scripts/engine_multitf_gpu.py(04d71e8), 그리고 scripts/GPU_NOTES.md의 설계 노트입니다.
Metal에는 float64가 없다

CPU에서는 Python/numpy 백테스트가 기본적으로 배정밀도로 실행됩니다. float64는 52비트 가수부(mantissa)를 제공합니다: 정수는 2⁵³ ≈ 9×10¹⁵까지 정확하게 표현되고, 상대 정밀도는 약 1.1×10⁻¹⁶입니다. 여러분은 이것을 거의 신경 쓸 일이 없습니다. 가격 스케일에서는 — BTC 종가가 30,000 언저리이고, 누적합이 10¹⁴에 도달하더라도 — fp64에 여유가 넘치기 때문입니다.
Apple의 GPU는 그런 여유를 제공하지 않습니다. float64를 전혀 제공하지 않기 때문입니다. Metal Shading Language에는 double 타입이 없으며, MLX, PyTorch-MPS, 그리고 Apple 실리콘을 대상으로 하는 다른 모든 프레임워크가 이 제약을 그대로 물려받습니다. 켤 수 있는 플래그도 없고, 느리지만 정확한 대체 경로도 없습니다. 여러분의 연산이 GPU에 닿는 순간, 그것은 fp32로 일어납니다:
- 가수부(mantissa): 23비트 (암묵적 선행 비트를 포함하면 24비트). 정수는 2²⁴ = 16,777,216 ≈ 1.6×10⁷까지만 정확합니다.
- 상대 정밀도: ~1.2×10⁻⁷. 대략 유효숫자 일곱 자리, 그 이상은 없습니다.
이 1.6×10⁷이라는 천장이 이야기의 전부입니다. 1600만이라니 넉넉해 보이지만, 백테스트가 일상적으로 그보다 훨씬 큰 중간값을 만들어낸다는 사실을 알아채는 순간 이야기는 달라집니다. 중간값이 1.6×10⁷을 넘는 순간, fp32는 필요한 소수 구조는커녕 연속된 정수조차 표현하지 못하게 됩니다. 정밀도는 완만하게 저하되지 않습니다 — 절벽에서 떨어지듯 추락하며, 그 절벽은 여러분의 데이터가 아무 생각 없이 지나쳐 버리는 숫자에 있습니다.
함정은 fp32가 거의 항상 괜찮다는 데 있습니다. 백테스트의 대부분 — 가격, 수익률, PnL, 샤프 — 은 일곱 자리로 충분한 범위 안에 편안히 머뭅니다. 그래서 순진한 이식은 잘 작동하고, 스모크 테스트를 통과하고, 멀쩡해 보이는 결과를 냅니다. 실패는 정확히 하나의 연산에 국한됩니다 — 중간값이 10⁷을 넘어서는 바로 그 지점 — 그리고 그 연산은 모두가 가장 먼저 벡터화하는 바로 그것입니다.
유혹적인 정식화: 접두사 합 한 번으로 끝내는 WMA
우리 전략은 헐 이동평균(Hull moving average)에 기대고 있습니다. HMA는 세 개의 가중이동평균을 합성한 것이고, HMA3 변형은 네 개입니다. 길이 p인 윈도우에 대한 선형 커널 가중이동평균은 다음과 같습니다
15만 개 바에 걸쳐 수천 개의 파라미터 조합을 스윕할 때, WMA 컨볼루션이야말로 진짜 비용입니다. 그래서 자연스러운 본능 — CPU에서는 옳은 본능 — 은 **접두사 합(prefix sum)**을 이용해 각 WMA를 O(n·p) 대신 O(n)으로 만드는 것입니다. 두 개의 누적합을 한 번만 미리 계산합니다,
그러면 어떤 윈도우든 그 선형 가중합은 S1과 S2의 몇 개의 차분과 인덱스 이동으로 환원됩니다. 윈도우별 루프도, 윈도우별 리덕션도 필요 없습니다 — cumsum 두 번이면 WMA 전체 행렬이 배열 산술만으로 떨어져 나옵니다. 이것은 아름답게 벡터화되고, GPU의 병렬 스캔(parallel-scan) 프리미티브에 완벽하게 대응하며, fp64에서는 정확히 옳습니다.
하지만 fp32에서는 여러분이 할 수 있는 가장 나쁜 짓이기도 합니다. 그리고 그 이유는 S2 안에 숨어 있습니다.
S2 = mx.cumsum(j * price) # j is the global bar index: 0, 1, 2, ... , n-1
j · price 항이 문제입니다. j가 150,000까지 올라가고 price가 30,000 언저리라면, 마지막 항 하나만 해도 4.5×10⁹이고, S2는 그런 항 150,000개의 누적합입니다. 이것은 가격 스케일에 머무르지 않습니다. fp32가 이미 셈을 멈춰버린 영역까지 치솟습니다.
오버플로가 일어나는 지점: 함정의 산술

자릿수(order of magnitude)들을 나란히 놓아 봅시다. 실패 전체가 바로 거기에 있으니까요.
S2 = cumsum(j · price)는 대략 price · n²/2 ≈ 30,000 · (150,000)²/2 ≈ 3×10¹⁴에 도달합니다. ~10¹⁴이라고 부릅시다. 이제 fp32의 정확한 정수 표현 한계를 떠올려 보십시오: ~1.6×10⁷. 이 누적합은 fp32가 정확히 표현할 수 있는 마지막 정수를 일곱 자릿수나 넘어서 버립니다.
구체적으로 무슨 뜻일까요? 10¹⁴ 부근에서, 표현 가능한 두 fp32 값 사이의 간격 — 최하위 자리의 1단위, 즉 ULP — 은 약 2²³ ≈ 8×10⁶입니다. 즉 S2가 10¹⁴ 대에 올라가면, 그 값은 ±800만 이내에서만 알려질 뿐입니다. S2에 저장되는 모든 값은 ~8×10⁶의 가장 가까운 배수로 반올림된 것입니다.
이제 WMA 복원 과정이 이걸 가지고 무슨 짓을 하는지 보십시오. 한 윈도우의 가중합을 뽑아내려면, 인접한 두 S2 값을 빼야 합니다(거기에 S1 보정을 더해서). 그 두 S2 값은 각각 ~10¹⁴이고, 각각 ±8×10⁶의 반올림 잡음을 안고 있습니다. 그 둘의 진짜 차이 — 여러분이 실제로 원하는 윈도우 값 — 은 정규화 이후 가격 자체 규모인 ~3×10⁴ 정도의 WMA에 해당합니다. 그러므로 산술은 이렇게 됩니다:
이것이 가장 순수한 형태의 **손실 상쇄(catastrophic cancellation)**입니다: 각 피연산자의 반올림 오차(±8×10⁶)가 여러분이 복원하려는 답보다 더 큽니다. 신호가, 그것이 추출된 숫자들의 잡음 바닥보다 더 작습니다. 자릿수를 몇 개 잃는 정도가 아닙니다 — 전부 잃어버리고, 되돌아오는 것은 cumsum의 누적된 반올림에 의해 지배됩니다.
GPU_NOTES.md에서 측정된 결과: 가격 ~30,000, 15만 개 바에서 이런 식으로 계산한 WMA는 fp64 대비 최대 상대 오차가 ~211에 이릅니다. 211퍼센트가 아니라 — 211배입니다. 계산된 이동평균이 진짜 값에서 두 자릿수만큼 벗어날 수 있다는 뜻입니다. 그리고 이것을 버그가 아니라 함정으로 만드는 부분이 여기 있습니다: 이 계산은 끝까지 실행되고, 유한하고 그럴듯한 숫자를 반환합니다. 무한대로의 오버플로도, NaN도, 예외도 없습니다. 211배나 틀린 이동평균도 여전히 이동평균처럼 보입니다 — 매끄럽고, 유한하고, 상쇄가 마침 약하게 일어난 바에서는 대략 맞는 범위 안에 있습니다 — 그래서 신뢰할 수 있는 기준값과의 직접 비교가 아닌 그 어떤 정상성 검사도 통과해 버립니다. 여러분은 완전한 백테스트, 완전한 자산 곡선, 완전한 "최적" 파라미터 집합을 얻지만, 그 전부가 허구인 지표 위에 지어진 것입니다.
해법은 더 높은 정밀도가 아니다 — 다른 방식의 합이다

오차를 발견하고 나면 반사적으로 손이 가는 곳은 더 높은 정밀도입니다 — fp64로 누적하거나, 보정(Kahan) 합산을 쓰거나. Metal에서는 전자가 그냥 불가능합니다. 하지만 둘 다 필요 없습니다. 문제는 애초에 비트 수가 아니었기 때문입니다. 문제는 정식화(formulation)였습니다. 접두사 합 트릭은 10¹⁴ 스케일의 중간값을 만들어냈다가 다시 그것을 빼서 끌어내립니다. 그것이 만들어내는 크기는 답의 속성이 아니라 알고리즘의 인공물입니다. 그런 크기를 애초에 만들지 않는 정식화를 고르면 fp32로 충분합니다.
그 정식화는 바로 정의 그 자체입니다: 직접 윈도우 컨볼루션(direct windowed convolution). 두 개의 전역 누적합 대신, 길이 p인 선형 커널을 시리즈 위에서 슬라이딩하며 그 자리에서 합산합니다. 각 출력은 많아야 p ≈ 200개 항의 합이고, 모든 항은 weight × price인데 여기서 가중치는 합이 1이 되도록 정규화되어 있습니다 — 그래서 각 항은 price / p 정도의 크기이고, 모든 부분합은 가격 스케일(~3×10⁴) 근처에 머물며, 어떤 중간값도 fp32 천장에서 여섯 자릿수 이내로도 접근하지 않습니다. 상쇄할 것이 아무것도 없습니다. 애초에 부풀려진 것이 아무것도 없으니까요.
MLX에서는 이것이 프리미티브 하나 — mx.conv1d — 로 끝나며, 이것이야말로 GPU가 빠르게 처리하도록 만들어진 바로 그 연산입니다:
def _mx_wma_valid(x, period):
w = mx.arange(1, period + 1, dtype=mx.float32) / (period * (period + 1) / 2.0)
return mx.conv1d(x.reshape(1, -1, 1), w.reshape(1, period, 1), padding=0).reshape(-1)
동일한 WMA이며, 접두사 합 버전 및 CPU의 fp64 vec_wma/nb_wma와 수학적으로 동일합니다. 그런데 이제 측정된 fp64 대비 최대 상대 오차는 8.2×10⁻⁷입니다 — fp32의 잡음 바닥인 ~1.2×10⁻⁷ 바로 그 지점, 유효숫자 일곱 자리까지 일치합니다. 종이 위에서는 더 느려 보이는 정식화(O(n) 대신 O(n·p))가 유일하게 올바른 것이며 — 그리고 이것이 GPU가 바(bar)와 윈도우 양쪽에 걸쳐 동시에 병렬화하는 밀집 컨볼루션이기 때문에 — 놀랍도록 빠르기까지 합니다. 우리는 몇 비트로 합산하느냐가 아니라 어떻게 합산하느냐를 바꿈으로써 상대 오차를 211에서 8×10⁻⁷로 떨어뜨렸습니다.
이렇게 처리하다 보니 실무적으로 두 가지가 딸려 나옵니다. 첫째, MLX는 numpy처럼 conv1d를 통해 NaN을 전파해 주지 않으므로, 워밍업 구간(윈도우 평균이 정의되지 않는 첫 p−1개 바)을 GPU 위에서 NaN으로 표시할 수 없습니다. 하지만 그럴 필요도 없습니다: 각 시리즈의 유효한 시작점은 분석적으로 이미 알려져 있고, 유효하지 않은 접두 구간은 절대 읽히지 않는 0으로 채워지며, NaN 패딩은 이후 CPU에서 복원됩니다 — 벡터화 버전 및 numba 버전과 비트 단위로 동일한 유효성 마스크입니다. 둘째, 전체 스윕은 하나의 cand_close 시리즈를 공유하고 콤보 전반에서 윈도우를 대량으로 재사용하므로, 출력 채널이 많은 배치된 conv1d 하나가 스윕에 필요한 모든 고유 WMA를 한 번의 GPU 호출로 계산하며, mx.eval() 한 번으로 구체화됩니다.
빠지지 않았음을 증명하기: 트레이드 수로 보는 패리티
앞 절이 응당 던져야 할 불편한 질문이 여기 있습니다: 211배나 틀린 WMA도 여전히 WMA처럼 보인다면, 8×10⁻⁷짜리 버전이 실제로 옳고 그저 더 미묘하게 틀린 게 아니라는 걸 어떻게 압니까? 눈으로 훑어봐서는 알 수 없습니다. 파이프라인의 하류에 있는 이산적인 부분이 드러내는 불변량이 필요합니다 — 그리고 백테스트는 완벽한 것을 하나 손에 쥐여줍니다: 트레이드입니다.
사다리에 있는 다른 GPU 방식들(M0–M4)은 전부 fp64로 실행되므로, 우리는 그들에게 엄격한 동치성 단언(assert)을 적용합니다 — 동일한 트레이드 수, atol=1e-6까지 일치하는 PnL. fp32 GPU 방식(M5)은 그 구조상 이를 통과할 수 없으며, 이를 수용하려고 모두를 위한 단언을 조용히 느슨하게 만드는 것이야말로 이 시리즈가 맞서 싸우는 바로 그 종류의 부정직함일 것입니다. 그래서 M5는 자신만의 정량적 패리티 리포트인 report_equiv_fp32를 가지며, 추출된 트레이드를 fp64 기준값과 비교합니다.
남아 있는 불일치의 메커니즘은 정확히 이름 붙여둘 가치가 있습니다. 이것은 상쇄 참사가 아니기 때문입니다 — 예상할 법한, 평범하고 미미한 fp32 반올림일 뿐입니다. 전략은 두 헐 평균 h와 h3의 교차에서 발동합니다. 가격 ~30,000에서 지표의 상대 오차 ~1×10⁻⁶은 절대적으로는 ~0.03의 흔들림입니다. 대다수의 바에서는 두 곡선이 그보다 더 멀리 떨어져 있어서 교차가 명확합니다. 하지만 경계에 걸친 바 — h − h3 자체가 0에서 0.03 이내인 경우 — 에서는 그 흔들림이 비교의 부호를 뒤집어, 교차 시점을 바 하나만큼 옮기고, 트레이드 하나를 더하거나 뺄 수 있습니다.
그래서 "차이가 나는 콤보의 비율"은 무가치한 건강 지표이며, 우리의 첫 패리티 검사는 이걸 썼다가 스스로 망신을 당했습니다. 15만 개 바에서는 각 콤보마다 교차가 수천 번 일어나므로, 거의 모든 콤보에 경계에 걸친 바가 적어도 하나는 나타납니다 — 80개 콤보 중 37개가 "달랐다"는 것은 경보처럼 들리지만 아무 의미도 없습니다. 정말 중요한 지표는 얼마나 차이가 나느냐입니다:
- 80개 콤보 전체에 걸친 PnL 델타: max |Δ| = 1.843 퍼센트 포인트, 최대 상대값 = 1.25×10⁻²; 붕괴 임계값 5 p.p.
- 콤보당 트레이드 수 편차: 수천 개 중 max |Δn| = 4건, 최대 상대값 = 2.5×10⁻³; 붕괴 임계값 1%.
- 총계: 479,016건 중 90건의 트레이드가 이동 — 0.019%.
거의 50만 건 중 90건의 트레이드, 그 하나하나가 가격 틱보다도 작은 반올림 흔들림에 밀려난 경계상의 교차이며, 그 어느 것도 붕괴 임계값 근처에도 가지 않습니다. 이것이 올바른 fp32 방식의 특징입니다 — 작고, 유계이며, 설명 가능한 불일치 — 그리고 이것은 상대 오차 211과는 완전히 다른 종류의 짐승입니다. 임계값은 "뭐, 그냥 fp32니까"라고 위장한 망가진 정식화를 잡아내기 위해 존재합니다. 실제 델타는 그 임계값보다 한 자릿수 아래에서 들어옵니다. 트레이드 수는 자산 곡선이 되어주지 못한 신탁(oracle)입니다.
성과, 그리고 GPU가 더는 도움이 되지 않는 지점
정확성이 확립되었으니, 이제 속도를 말할 가치가 있습니다 — 그런 다음 정직하게 단서를 달아야 합니다. GPU의 이점이 파이프라인 전반에 고르게 퍼져 있지 않기 때문입니다.
순수한 WMA 컨볼루션만 떼어놓고 보면 — 이 방법 전체가 가속하려고 존재하는 바로 그 연산 — fp32 conv1d 배치는 8.2×10⁻⁷의 상대 오차로 단일 스레드 numba보다 55.9배 빠르게 실행됩니다. 이것이 깔끔하게 사과 대 사과로 비교한 GPU 대 CPU 수치입니다: 동일한 수학, 컴파일된 CPU 코드 한 스레드 대 Metal GPU.
하지만 스윕은 컨볼루션만으로 이루어지지 않습니다. HMA/HMA3 행렬이 GPU에서 계산되고 나면, 트레이드는 여전히 추출되어야 합니다 — 각 콤보의 교차를 훑는 O(n) 워크 — 그리고 이것은 GPU 위에 다시 구현하는 대신 다른 방식들의 정확한 트레이드 시맨틱을 그대로 재사용하며 CPU에서 fp64로 수행합니다. 엔드투엔드 timed() 수치는 모든 것을 포함합니다: 커널 워밍업은 제외되지만(numba의 컴파일을 제외하는 것과 대칭적으로), GPU→CPU 전송과 CPU 트레이드 추출은 포함됩니다. 15만 개 바 × 80개 콤보, 3회 중 최선값, M2 Max에서:
| Method | Wall | Speedup vs baseline | combos/s |
|---|---|---|---|
| M0 pandas + Python 루프* | 287.08s | 1.0× | 0.3 |
| M1 벡터화된 numpy | 3.14s | 91.5× | 25.5 |
| M2 numba (직렬) | 2.02s | 142.3× | 39.7 |
| M3 멀티프로세스 + 벡터화 | 0.50s | 570.2× | 158.9 |
| M4 멀티프로세스 + numba (12코어) | 0.33s | 882.5× | 245.9 |
| M5 MLX GPU (fp32) | 0.10s | 2796.0× | 779.2 |
*M0은 균일한 5개 콤보 샘플로부터 외삽되었습니다.
완전한 엔진인 M5는 779 콤보/초를 냅니다 — pandas 기준선 대비 2,796배, 직렬 numba 대비 19.6배, 그리고 numba를 돌리는 12코어 CPU 풀 전체 대비 3.2배입니다. GPU 하나가 이 머신이 가진 모든 CPU 코어를 세 배로 이깁니다.
이제 정직한 단서를 답니다: 엔드투엔드 GPU 이점(M2 대비 19.6배)이 컨볼루션만의 이점(numba 대비 55.9배)보다 작다는 점을 주목하십시오. 그 격차는 예정대로 도착한 암달의 법칙입니다. GPU가 컨볼루션을 너무나 완전히 소멸시킨 나머지 그것들은 더는 병목이 아니게 되었습니다. 남은 것 — GPU가 전혀 빠르게 만들어 주지 않은 O(n) CPU 트레이드 추출 — 이 이제 M5의 실행 시간을 지배합니다. 이것은 이 시리즈의 속도 사다리와 IPC 세금 글이 계속해서 도달하는 것과 동일한 교훈입니다: 어느 지점을 넘어서면, 승리는 "빠른 부분을 더 빠르게 만드는 것"이 아니라 오케스트레이션입니다 — 데이터가 어디에 사는지, 어느 단계가 이제 직렬이 되었는지, 디바이스와 호스트 사이를 오가는 데 무엇을 지불하고 있는지. 트레이드 추출을 커스텀 Metal 커널로 밀어 넣는 가상의 M6를 좇는다 해도, 갈수록 줄어드는 CPU 몫만을 되찾을 뿐입니다. 그래서 우리는 그것을 만들지 않았습니다.
일반적인 교훈: 소리 없는 수치적 쓰레기가 기본값이다
헐 평균과 MLX에서 한 발 물러나 봅시다. 이 함정은 이 지표 하나를 훨씬 넘어 일반화되기 때문입니다.
GPU 백테스트의 매혹적인 세일즈 포인트는 "하나의 큰 행렬"입니다: 모든 파라미터 조합을 텐서 하나에 쌓고, 스윕 전체를 몇 개의 밀집 배열 연산으로 실행하고, 하드웨어가 그것을 먹어 치우게 두는 것입니다. 그 세일즈 포인트는 진짜입니다 — 위의 속도 향상들도 진짜입니다. 하지만 그것은 여러분 밑에 깔린 수치 체제를 조용히 바꿔놓고, 그 변화는 코드에서 눈에 보이지 않습니다. CPU에서는 기본값들이 여러분을 보호해 주었습니다: fp64, NaN 전파, 알아채지도 못한 채 10¹⁴까지 실행될 수 있는 cumsum. 동일한 배열 표현식을 Metal로 옮기면 여러분은 1.6×10⁷이라는 단단한 정수 천장을 가진 fp32 안에 있게 되고, 동일한 코드 한 줄 — cumsum(j * price) — 이 정확함에서 쓰레기로 바뀝니다. 문법상 아무것도 경고해 주지 않습니다. 컴파일러는 만족스러워합니다. 출력은 유한하고 그럴듯합니다. fp32는 요란하게 실패하지 않습니다 — 숫자를 들고 정중하게 실패합니다.
여러분을 실제로 지켜주는 세 가지 습관은 저렴합니다:
- 입력과 출력뿐 아니라, 중간값이 어디에 사는지도 알아두십시오. 입력(가격 ~10⁴)과 출력(WMA ~10⁴)은 둘 다 fp32의 정확한 범위 안에 편안히 있었습니다. 재앙은 전적으로, API도 타입도 드러내 주지 않는 숨겨진 중간값(
S2~10¹⁴) 안에 있었습니다. 어떤 fp32 리덕션이든 신뢰하기 전에, 가장 큰 부분합이 어디까지 도달하는지 물어보십시오 — 그리고 그것이 ~10⁷을 넘는다면, 정식화를 바꾸십시오. - 크기를 유계로 유지하는 정식화를 선호하십시오. 접두사 합보다 직접 컨볼루션을, 전역 스캔보다 지역 윈도우를, 합산 이후가 아니라 이전에 중심화/차분을. 크게 부풀렸다가 상쇄하는 것이 안티패턴입니다. 올바른 알고리즘은 종종 종이 위에서는 점근적으로 더 나빠 보이지만 상쇄해야 할 값을 애초에 만들어내지 않는 그것입니다.
- 이산적인 불변량을 통해 fp64 신탁(oracle)과 대조 검증하십시오. 곡선을 비교하지 마십시오. 양자화되고 하류에 있는 것 — 트레이드 수, 교차 횟수, 포지션 변경 이벤트 — 을 비교하십시오. 이산적인 불변량은 소리 없는 211배 오차를 비명을 지르는 단언 실패로 바꾸고, 받아들일 만한 8×10⁻⁷ 오차를 작고, 유계이며, 설명 가능한 델타로 바꿉니다. 이것은 미래 참조 편향에 대한 한-바 이동 테스트와 동일한 규율입니다: 보이지 않는 실패를 보이는 실패로 바꿔주는 저렴한 진단법입니다.
이 중 어느 것도 이국적인 수치해석이 아닙니다. 느리고 신뢰할 수 있는 백테스트가 확인해 주기 전까지는 빠른 백테스트를 믿지 않는, 평범한 위생 습관을 — 언어가 정밀도가 소리 없이 열두 자릿수만큼 떨어졌다고 더는 경고해 주지 않는 바로 그 한 지점까지 확장한 것일 뿐입니다.
핵심 요점

- Apple의 GPU에는 float64가 없습니다 — 여러분의 백테스트에서 GPU가 다루는 모든 숫자는 fp32입니다. 정수는 ~1.6×10⁷까지만 정확하고 정밀도는 ~1.2×10⁻⁷입니다. 플래그도, 대체 경로도 없습니다. 백테스트 대부분은 이를 견뎌내지만, 대개 정확히 하나의 연산은 그렇지 못합니다.
- 접두사 합 WMA가 바로 그 함정입니다.
cumsum(j · price)는 ~10¹⁴까지 치솟아 fp32의 정확한 천장을 일곱 자릿수나 넘어서고, 윈도우를 복원하려면 반올림 오차(±8×10⁶)가 이미 답을 압도해 버리는 그런 두 숫자를 빼야만 합니다. 측정된 최대 상대 오차: 211배. 절대 충돌하지 않습니다 — 그럴듯한 쓰레기를 반환할 뿐입니다. - 해법은 비트를 더 쓰는 것이 아니라 다른 방식의 합입니다. 직접 윈도우 컨볼루션(
mx.conv1d)은 모든 부분합을 가격 스케일 근처에 유지하므로, fp32는 정직한 유효숫자 일곱 자리를 지킵니다: 상대 오차 8.2×10⁻⁷, 그리고 단일 스레드 numba보다 55.9배 빠름. Metal에서는 fp64를 돈 주고도 살 수 없고, 살 필요도 없습니다. - 곡선이 아니라 이산적인 불변량으로 검증하십시오. 트레이드 수 패리티가 이를 잡아냈습니다: fp32 conv1d는 fp64와 **479,016건 중 90건(0.019%)**의 트레이드에서 불일치했으며, 전부 경계상의 교차였고, 전부 붕괴 임계값을 한참 밑돌았습니다 — 211배 오차와는 확연히 다른, 올바른 방식의 특징입니다. "차이가 나는 콤보의 비율"은 눈속임 지표입니다. 얼마나 차이가 나는지를 측정하십시오.
- 전체 스윕은 779 콤보/초를 냅니다 — pandas 기준선 대비 2,796배, 12코어 CPU 풀 전체 대비 3.2배 — 하지만 엔드투엔드 이득(직렬 numba 대비 19.6배)은 컨볼루션만의 이득(55.9배)보다 작습니다. CPU 트레이드 추출이 이제 병목이기 때문입니다. 어느 지점을 넘어서면, 속도는 산술이 아니라 오케스트레이션입니다.
GPU 이식은 2,796배 빨랐고, 처음 작동한 버전에서는 완전히 틀렸습니다 — 그리고 이 두 사실은 서로 아무 관계가 없었습니다. 속도는 진짜였습니다. 쓰레기는 fp32가 담을 수 없었고 그 어떤 오류 메시지도 언급해 주지 않았던, 숨겨진 10¹⁴ 중간값이었습니다. 백테스트가 극적으로 빨라지고 숫자가 여전히 멀쩡해 보인다면, 그것은 확증이 아닙니다. Metal에서는 "멀쩡해 보이는 것"이 바로 상대 오차 211이 보이는 모습입니다.
이것이 이 시리즈가 올라온 사다리에서 GPU 단(段)입니다: 백테스트 엔진 속도 사다리, 멀티프로세싱의 IPC 세금, 누출에 대한 미래 참조 편향 분류, 그리고 "좋다"는 것이 대체 무엇을 뜻하는지 결정하는 목적함수 설계. 잘못된 숫자를 빠르게 계산해 낸다면 속도는 무가치합니다.
Authors
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.